@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,84 @@
1
+ package user
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "time"
8
+
9
+ "github.com/golang-jwt/jwt/v5"
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ const (
14
+ tokenTypeAccess = "access"
15
+ tokenTypeOAuthState = "oauth_state"
16
+ oauthStateTTL = 10 * time.Minute
17
+ )
18
+
19
+ // accessClaims — see internal/shared/middleware/auth.go for why this struct
20
+ // is duplicated there instead of imported. Also reused (with no Subject) for
21
+ // the Google OAuth CSRF state token below — Typ is what stops one kind being
22
+ // presented where the other is expected.
23
+ type accessClaims struct {
24
+ Typ string `json:"typ"`
25
+ // go-scaffold:jwt-claims
26
+ jwt.RegisteredClaims
27
+ }
28
+
29
+ func (s *Service) issueAccessToken(
30
+ userID uuid.UUID,
31
+ // go-scaffold:issue-access-token-params
32
+ ) (string, error) {
33
+ claims := accessClaims{
34
+ Typ: tokenTypeAccess,
35
+ // go-scaffold:jwt-claims-values
36
+ RegisteredClaims: jwt.RegisteredClaims{
37
+ Subject: userID.String(),
38
+ IssuedAt: jwt.NewNumericDate(time.Now()),
39
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTTL)),
40
+ },
41
+ }
42
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
43
+ }
44
+
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) {
49
+ claims := accessClaims{
50
+ Typ: tokenTypeOAuthState,
51
+ RegisteredClaims: jwt.RegisteredClaims{
52
+ IssuedAt: jwt.NewNumericDate(time.Now()),
53
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
54
+ },
55
+ }
56
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
57
+ }
58
+
59
+ func (s *Service) verifyOAuthState(raw string) error {
60
+ var claims accessClaims
61
+ _, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
62
+ return []byte(s.jwtSecret), nil
63
+ }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
64
+ if err != nil || claims.Typ != tokenTypeOAuthState {
65
+ return errInvalidToken()
66
+ }
67
+ return nil
68
+ }
69
+
70
+ // randomToken generates an opaque refresh token — deliberately not a JWT:
71
+ // nothing needs to read claims out of it, and only its hash (see hashToken)
72
+ // is ever stored, so a leaked server-side store never exposes usable tokens.
73
+ func randomToken() (string, error) {
74
+ b := make([]byte, 32)
75
+ if _, err := rand.Read(b); err != nil {
76
+ return "", err
77
+ }
78
+ return hex.EncodeToString(b), nil
79
+ }
80
+
81
+ func hashToken(raw string) string {
82
+ sum := sha256.Sum256([]byte(raw))
83
+ return hex.EncodeToString(sum[:])
84
+ }
@@ -0,0 +1,28 @@
1
+ package model
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/google/uuid"
7
+ )
8
+
9
+ type Provider string
10
+
11
+ const (
12
+ ProviderLocal Provider = "local"
13
+ ProviderGoogle Provider = "google"
14
+ )
15
+
16
+ // Identity = how you log in (one row per login method). PasswordHash is set
17
+ // for a "local" identity; ProviderUID is set for "google" (the provider's own
18
+ // subject id). Split from User so adding a login method never touches the
19
+ // profile row — one person can have both and resolve to the same account.
20
+ type Identity struct {
21
+ ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
22
+ UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
23
+ Provider Provider `json:"provider" gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
24
+ PasswordHash *string `json:"-"`
25
+ ProviderUID *string `json:"-" gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
26
+ CreatedAt time.Time `json:"created_at"`
27
+ UpdatedAt time.Time `json:"updated_at"`
28
+ }
@@ -0,0 +1,22 @@
1
+ // Package model holds this domain's GORM tables.
2
+ package model
3
+
4
+ import (
5
+ "time"
6
+
7
+ "github.com/google/uuid"
8
+ )
9
+
10
+ // User = who you are (profile). How you log in is a separate concept — see
11
+ // Identity — so the same person can have both a password and a Google login
12
+ // resolving to one account.
13
+ type User struct {
14
+ ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
15
+ Email string `json:"email" gorm:"uniqueIndex;not null"`
16
+ Name string `json:"name"`
17
+ AvatarURL string `json:"avatar_url"`
18
+ EmailVerified bool `json:"email_verified"`
19
+ // go-scaffold:user-fields
20
+ CreatedAt time.Time `json:"created_at"`
21
+ UpdatedAt time.Time `json:"updated_at"`
22
+ }
@@ -0,0 +1,84 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{goModule}}/internal/app/user/model"
7
+
8
+ "github.com/google/uuid"
9
+ "gorm.io/gorm"
10
+ )
11
+
12
+ type Repository struct {
13
+ db *gorm.DB
14
+ }
15
+
16
+ func NewRepository(db *gorm.DB) *Repository {
17
+ return &Repository{db: db}
18
+ }
19
+
20
+ func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
21
+ var u model.User
22
+ if err := r.db.WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
23
+ return nil, err
24
+ }
25
+ return &u, nil
26
+ }
27
+
28
+ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
29
+ var u model.User
30
+ if err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
31
+ return nil, err
32
+ }
33
+ return &u, nil
34
+ }
35
+
36
+ func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
37
+ return r.db.WithContext(ctx).Save(u).Error
38
+ }
39
+
40
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
41
+ var items []model.User
42
+ err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
43
+ return items, err
44
+ }
45
+
46
+ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
47
+ var i model.Identity
48
+ if err := r.db.WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
49
+ return nil, err
50
+ }
51
+ return &i, nil
52
+ }
53
+
54
+ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
55
+ var i model.Identity
56
+ if err := r.db.WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
57
+ return nil, err
58
+ }
59
+ return &i, nil
60
+ }
61
+
62
+ // CreateIdentity links a new login method onto an EXISTING user (e.g.
63
+ // Google linking onto an account that registered with a password first) —
64
+ // see CreateUserWithIdentity for the "brand new user" case.
65
+ func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
66
+ return r.db.WithContext(ctx).Create(i).Error
67
+ }
68
+
69
+ func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
70
+ return r.db.WithContext(ctx).Save(i).Error
71
+ }
72
+
73
+ // CreateUserWithIdentity inserts the profile and its first login method in
74
+ // one transaction — a user with no identity at all can't log in any way, so
75
+ // the two rows always exist together or not at all.
76
+ 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 {
79
+ return err
80
+ }
81
+ i.UserID = u.ID
82
+ return tx.Create(i).Error
83
+ })
84
+ }
@@ -0,0 +1,447 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "log/slog"
8
+ "net/http"
9
+ "strings"
10
+ "time"
11
+
12
+ "{{goModule}}/internal/app/user/model"
13
+ "{{goModule}}/internal/shared/apperror"
14
+ "{{goModule}}/internal/shared/config"
15
+ "{{goModule}}/internal/shared/dberr"
16
+ "{{goModule}}/internal/shared/id"
17
+
18
+ "github.com/google/uuid"
19
+ "golang.org/x/crypto/bcrypt"
20
+ "golang.org/x/oauth2"
21
+ "golang.org/x/oauth2/google"
22
+ "gorm.io/gorm"
23
+ )
24
+
25
+ // repository = what the service needs from the data layer (declared on the
26
+ // consumer side, so it can be faked in tests).
27
+ type repository interface {
28
+ FindByEmail(ctx context.Context, email string) (*model.User, error)
29
+ FindByID(ctx context.Context, id uuid.UUID) (*model.User, error)
30
+ UpdateUser(ctx context.Context, u *model.User) error
31
+ FindAll(ctx context.Context, limit, offset int) ([]model.User, error)
32
+ FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error)
33
+ FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error)
34
+ CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error
35
+ CreateIdentity(ctx context.Context, i *model.Identity) error
36
+ UpdateIdentity(ctx context.Context, i *model.Identity) error
37
+ // go-scaffold:user-repository-interface
38
+ }
39
+
40
+ // mailer = what the service needs to send an email — satisfied by
41
+ // platform/mail's AsyncClient (enqueues onto cmd/worker instead of blocking
42
+ // the request on SMTP).
43
+ type mailer interface {
44
+ Send(to, subject, body string) error
45
+ }
46
+
47
+ // go-scaffold:user-interfaces
48
+
49
+ type Service struct {
50
+ repo repository
51
+ tokens tokenStore
52
+ mailer mailer
53
+ jwtSecret string
54
+ accessTTL time.Duration
55
+ refreshTTL time.Duration
56
+ resetTTL time.Duration
57
+ resetURL string
58
+ verifyTTL time.Duration
59
+ verifyURL string
60
+ googleOAuth *oauth2.Config
61
+ // go-scaffold:user-service-fields
62
+ }
63
+
64
+ func NewService(
65
+ repo repository,
66
+ tokens tokenStore,
67
+ mailer mailer,
68
+ cfg config.Config,
69
+ // go-scaffold:user-service-params
70
+ ) *Service {
71
+ return &Service{
72
+ repo: repo,
73
+ tokens: tokens,
74
+ mailer: mailer,
75
+ jwtSecret: cfg.JWTSecret,
76
+ accessTTL: cfg.JWTAccessTTL,
77
+ refreshTTL: cfg.JWTRefreshTTL,
78
+ resetTTL: cfg.PasswordResetTTL,
79
+ resetURL: cfg.PasswordResetURL,
80
+ verifyTTL: cfg.EmailVerifyTTL,
81
+ verifyURL: cfg.EmailVerifyURL,
82
+ googleOAuth: &oauth2.Config{
83
+ ClientID: cfg.GoogleClientID,
84
+ ClientSecret: cfg.GoogleClientSecret,
85
+ RedirectURL: cfg.GoogleRedirectURL,
86
+ Scopes: []string{"openid", "email", "profile"},
87
+ Endpoint: google.Endpoint,
88
+ },
89
+ // go-scaffold:user-service-init
90
+ }
91
+ }
92
+
93
+ func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse, error) {
94
+ hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
95
+ if err != nil {
96
+ return nil, apperror.NewInternal()
97
+ }
98
+ hashStr := string(hash)
99
+
100
+ u := &model.User{ID: id.New(), Email: in.Email, Name: in.Name}
101
+ i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
102
+ if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
103
+ if dberr.IsDuplicate(err) {
104
+ return nil, errEmailTaken()
105
+ }
106
+ return nil, apperror.NewInternal()
107
+ }
108
+ // best-effort: a mail failure shouldn't block registration — the user can
109
+ // always ask for another link via ResendVerificationEmail.
110
+ s.sendVerificationEmail(ctx, u)
111
+ return s.issueTokens(ctx, u)
112
+ }
113
+
114
+ func (s *Service) Login(ctx context.Context, in loginInput) (*authResponse, error) {
115
+ u, err := s.repo.FindByEmail(ctx, in.Email)
116
+ if err != nil {
117
+ return nil, errInvalidCredentials()
118
+ }
119
+ ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
120
+ if err != nil || ident.PasswordHash == nil {
121
+ return nil, errInvalidCredentials()
122
+ }
123
+ if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
124
+ return nil, errInvalidCredentials()
125
+ }
126
+ return s.issueTokens(ctx, u)
127
+ }
128
+
129
+ // Refresh rotates a refresh token: the presented one is consumed (deleted +
130
+ // tombstoned as "used") and a fresh pair is issued. If the presented token
131
+ // isn't active but WAS already used, that means this exact raw value got
132
+ // replayed after rotation — i.e. it leaked — so every session for that user
133
+ // is revoked, not just this one.
134
+ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
135
+ hash := hashToken(rawRefreshToken)
136
+ userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
137
+ if err != nil {
138
+ return nil, apperror.NewInternal()
139
+ }
140
+ if !ok {
141
+ if reusedBy, used, uerr := s.tokens.IsRefreshTokenUsed(ctx, hash); uerr == nil && used {
142
+ _ = s.tokens.RevokeAllRefreshTokens(ctx, reusedBy)
143
+ }
144
+ return nil, errInvalidToken()
145
+ }
146
+
147
+ if err := s.tokens.DeleteRefreshToken(ctx, hash, userID); err != nil {
148
+ return nil, apperror.NewInternal()
149
+ }
150
+ if err := s.tokens.MarkRefreshTokenUsed(ctx, hash, userID, s.refreshTTL); err != nil {
151
+ return nil, apperror.NewInternal()
152
+ }
153
+
154
+ u, err := s.repo.FindByID(ctx, userID)
155
+ if err != nil {
156
+ return nil, errInvalidToken()
157
+ }
158
+ return s.issueTokens(ctx, u)
159
+ }
160
+
161
+ // Logout is intentional, not reuse — no tombstoning, and idempotent (a
162
+ // missing/already-gone token still succeeds).
163
+ func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
164
+ if rawRefreshToken == "" {
165
+ return nil
166
+ }
167
+ hash := hashToken(rawRefreshToken)
168
+ userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
169
+ if err != nil || !ok {
170
+ return nil
171
+ }
172
+ return s.tokens.DeleteRefreshToken(ctx, hash, userID)
173
+ }
174
+
175
+ // LogoutAll is the authenticated, self-service version of what ResetPassword
176
+ // already does automatically: end every session for this user, not just the
177
+ // one making the request — e.g. "log out everywhere" after a lost device.
178
+ func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
179
+ return s.tokens.RevokeAllRefreshTokens(ctx, userID)
180
+ }
181
+
182
+ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error) {
183
+ u, err := s.repo.FindByID(ctx, userID)
184
+ if err != nil {
185
+ if errors.Is(err, gorm.ErrRecordNotFound) {
186
+ return nil, errNotFound()
187
+ }
188
+ return nil, apperror.NewInternal()
189
+ }
190
+ return u, nil
191
+ }
192
+
193
+ // List is unused until an admin-facing route calls it (see `add rbac`'s
194
+ // GET /users) — kept here rather than gated behind a patch since it's a
195
+ // generically useful piece of data access, same as UpdateUser.
196
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, error) {
197
+ items, err := s.repo.FindAll(ctx, limit, offset)
198
+ if err != nil {
199
+ return nil, apperror.NewInternal()
200
+ }
201
+ return items, nil
202
+ }
203
+
204
+ // ForgotPassword always succeeds from the caller's point of view — whether
205
+ // the email exists or not — so a login-attempt-shaped probe can't be used to
206
+ // enumerate registered accounts. Only if the email resolves to a local
207
+ // (password-based) identity does it actually issue+email a reset token.
208
+ func (s *Service) ForgotPassword(ctx context.Context, email string) error {
209
+ u, err := s.repo.FindByEmail(ctx, email)
210
+ if err != nil {
211
+ return nil
212
+ }
213
+ ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
214
+ if err != nil || ident.PasswordHash == nil {
215
+ return nil // google-only account has no password to reset
216
+ }
217
+
218
+ raw, err := randomToken()
219
+ if err != nil {
220
+ return apperror.NewInternal()
221
+ }
222
+ if err := s.tokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.resetTTL); err != nil {
223
+ return apperror.NewInternal()
224
+ }
225
+
226
+ link := s.resetURL + "?token=" + raw
227
+ if err := s.mailer.Send(u.Email, "Reset your password", "Reset your password: "+link); err != nil {
228
+ // token's already stored — a mail failure shouldn't fail the request,
229
+ // just get logged so it's visible operationally.
230
+ slog.Error("send password reset email", "error", err)
231
+ }
232
+ return nil
233
+ }
234
+
235
+ // ResetPassword consumes a one-time reset token (GETDEL — see
236
+ // tokenStore.ConsumePasswordResetToken), sets the new password, and revokes
237
+ // every existing session for that user: a password reset should end every
238
+ // session an attacker (or the legitimate user on another device) still holds.
239
+ func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
240
+ userID, ok, err := s.tokens.ConsumePasswordResetToken(ctx, hashToken(rawToken))
241
+ if err != nil {
242
+ return apperror.NewInternal()
243
+ }
244
+ if !ok {
245
+ return errInvalidToken()
246
+ }
247
+
248
+ hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
249
+ if err != nil {
250
+ return apperror.NewInternal()
251
+ }
252
+ hashStr := string(hash)
253
+
254
+ ident, err := s.repo.FindIdentity(ctx, userID, model.ProviderLocal)
255
+ if err != nil {
256
+ return apperror.NewInternal()
257
+ }
258
+ ident.PasswordHash = &hashStr
259
+ if err := s.repo.UpdateIdentity(ctx, ident); err != nil {
260
+ return apperror.NewInternal()
261
+ }
262
+ return s.tokens.RevokeAllRefreshTokens(ctx, userID)
263
+ }
264
+
265
+ // sendVerificationEmail issues a one-time token and emails the link — called
266
+ // from Register (best-effort) and ResendVerificationEmail. Not exported:
267
+ // callers that want to send one go through one of those two, which decide
268
+ // whether it's appropriate to (e.g. ResendVerificationEmail checks the user
269
+ // isn't already verified first).
270
+ func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
271
+ raw, err := randomToken()
272
+ if err != nil {
273
+ slog.Error("generate email verification token", "error", err)
274
+ return
275
+ }
276
+ if err := s.tokens.SetEmailVerifyToken(ctx, hashToken(raw), u.ID, s.verifyTTL); err != nil {
277
+ slog.Error("store email verification token", "error", err)
278
+ return
279
+ }
280
+ link := s.verifyURL + "?token=" + raw
281
+ if err := s.mailer.Send(u.Email, "Verify your email", "Verify your email: "+link); err != nil {
282
+ slog.Error("send email verification email", "error", err)
283
+ }
284
+ }
285
+
286
+ // ResendVerificationEmail is the authenticated counterpart to the automatic
287
+ // send in Register — for when the first email never arrived or its token
288
+ // expired. Rejects an already-verified user instead of silently no-op'ing:
289
+ // unlike ForgotPassword this is authenticated, so there's no enumeration
290
+ // concern in telling the caller their own account's real state.
291
+ func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID) error {
292
+ u, err := s.repo.FindByID(ctx, userID)
293
+ if err != nil {
294
+ return apperror.NewInternal()
295
+ }
296
+ if u.EmailVerified {
297
+ return errAlreadyVerified()
298
+ }
299
+ s.sendVerificationEmail(ctx, u)
300
+ return nil
301
+ }
302
+
303
+ // VerifyEmail consumes a one-time verification token (GETDEL — see
304
+ // tokenStore.ConsumeEmailVerifyToken) and marks the user verified.
305
+ func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
306
+ userID, ok, err := s.tokens.ConsumeEmailVerifyToken(ctx, hashToken(rawToken))
307
+ if err != nil {
308
+ return apperror.NewInternal()
309
+ }
310
+ if !ok {
311
+ return errInvalidToken()
312
+ }
313
+ u, err := s.repo.FindByID(ctx, userID)
314
+ if err != nil {
315
+ return apperror.NewInternal()
316
+ }
317
+ u.EmailVerified = true
318
+ return s.repo.UpdateUser(ctx, u)
319
+ }
320
+
321
+ // GoogleLoginURL signs a short-lived CSRF state token and builds the
322
+ // redirect URL — stateless, no server-side row for the state (see jwt.go).
323
+ func (s *Service) GoogleLoginURL() (string, error) {
324
+ state, err := s.issueOAuthState()
325
+ if err != nil {
326
+ return "", apperror.NewInternal()
327
+ }
328
+ return s.googleOAuth.AuthCodeURL(state), nil
329
+ }
330
+
331
+ type googleUserInfo struct {
332
+ Sub string `json:"sub"`
333
+ Email string `json:"email"`
334
+ EmailVerified bool `json:"email_verified"`
335
+ Name string `json:"name"`
336
+ Picture string `json:"picture"`
337
+ }
338
+
339
+ func (s *Service) GoogleCallback(ctx context.Context, code, state string) (*authResponse, error) {
340
+ if err := s.verifyOAuthState(state); err != nil {
341
+ return nil, err
342
+ }
343
+ tok, err := s.googleOAuth.Exchange(ctx, code)
344
+ if err != nil {
345
+ return nil, errInvalidToken()
346
+ }
347
+
348
+ resp, err := s.googleOAuth.Client(ctx, tok).Get("https://openidconnect.googleapis.com/v1/userinfo")
349
+ if err != nil {
350
+ return nil, apperror.NewInternal()
351
+ }
352
+ defer func() { _ = resp.Body.Close() }()
353
+ if resp.StatusCode != http.StatusOK {
354
+ return nil, apperror.NewInternal()
355
+ }
356
+ var info googleUserInfo
357
+ if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
358
+ return nil, apperror.NewInternal()
359
+ }
360
+
361
+ u, err := s.findOrCreateGoogleUser(ctx, info)
362
+ if err != nil {
363
+ return nil, err
364
+ }
365
+ return s.issueTokens(ctx, u)
366
+ }
367
+
368
+ // findOrCreateGoogleUser: (1) an existing google identity for this sub wins
369
+ // outright; (2) failing that, ONLY if Google says the email is verified, link
370
+ // a new google identity onto an existing local account with that email — an
371
+ // unverified email can't be used to take over someone else's account; (3)
372
+ // otherwise, brand new user + identity.
373
+ func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInfo) (*model.User, error) {
374
+ if ident, err := s.repo.FindIdentityByProviderUID(ctx, model.ProviderGoogle, info.Sub); err == nil {
375
+ return s.repo.FindByID(ctx, ident.UserID)
376
+ }
377
+
378
+ providerUID := info.Sub
379
+ if info.EmailVerified {
380
+ if u, err := s.repo.FindByEmail(ctx, info.Email); err == nil {
381
+ ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: model.ProviderGoogle, ProviderUID: &providerUID}
382
+ if err := s.repo.CreateIdentity(ctx, ident); err != nil {
383
+ return nil, apperror.NewInternal()
384
+ }
385
+ return u, nil
386
+ }
387
+ }
388
+
389
+ u := &model.User{ID: id.New(), Email: info.Email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
390
+ ident := &model.Identity{ID: id.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID}
391
+ if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
392
+ if dberr.IsDuplicate(err) {
393
+ return nil, errEmailTaken()
394
+ }
395
+ return nil, apperror.NewInternal()
396
+ }
397
+ return u, nil
398
+ }
399
+
400
+ // EnsureUser is idempotent: an existing user with this email is returned
401
+ // as-is (password untouched — cmd/seed shouldn't silently reset a real
402
+ // password on every deploy), otherwise a new local user+identity is
403
+ // created. Used by cmd/seed only — nothing in the HTTP API calls this.
404
+ func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
405
+ email = strings.ToLower(strings.TrimSpace(email))
406
+ if u, err := s.repo.FindByEmail(ctx, email); err == nil {
407
+ return u, nil
408
+ }
409
+
410
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
411
+ if err != nil {
412
+ return nil, apperror.NewInternal()
413
+ }
414
+ hashStr := string(hash)
415
+
416
+ u := &model.User{ID: id.New(), Email: email, Name: name}
417
+ i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
418
+ if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
419
+ return nil, apperror.NewInternal()
420
+ }
421
+ return u, nil
422
+ }
423
+
424
+ // go-scaffold:user-service-methods
425
+
426
+ func (s *Service) issueTokens(ctx context.Context, u *model.User) (*authResponse, error) {
427
+ access, err := s.issueAccessToken(
428
+ u.ID,
429
+ // go-scaffold:issue-access-token-args
430
+ )
431
+ if err != nil {
432
+ return nil, apperror.NewInternal()
433
+ }
434
+ refresh, err := randomToken()
435
+ if err != nil {
436
+ return nil, apperror.NewInternal()
437
+ }
438
+ if err := s.tokens.SetRefreshToken(ctx, hashToken(refresh), u.ID, s.refreshTTL); err != nil {
439
+ return nil, apperror.NewInternal()
440
+ }
441
+ return &authResponse{
442
+ AccessToken: access,
443
+ RefreshToken: refresh,
444
+ TokenType: "Bearer",
445
+ ExpiresIn: int(s.accessTTL.Seconds()),
446
+ }, nil
447
+ }