@nakedev/go-scaffold 0.4.0 → 0.5.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 (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -0,0 +1,157 @@
1
+ // Package application contains role management use cases. It owns the rules
2
+ // for permission grants and never imports GORM, Gin, or HTTP error types.
3
+ package application
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ "regexp"
9
+ "slices"
10
+
11
+ "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/app/role/ports"
13
+ )
14
+
15
+ type ServicePort interface {
16
+ PermissionsOf(context.Context, string) (map[string]struct{}, error)
17
+ CodeExists(context.Context, string) (bool, error)
18
+ List(context.Context, int, int) ([]RoleListItem, error)
19
+ Create(context.Context, CreateInput) (*RoleListItem, error)
20
+ SetPermissions(context.Context, string, []string) (*RoleListItem, error)
21
+ Delete(context.Context, string) error
22
+ ListPermissions(context.Context, int, int) ([]domain.Permission, error)
23
+ }
24
+
25
+ type Service struct {
26
+ repo ports.Repository
27
+ }
28
+
29
+ func NewService(repo ports.Repository) *Service { return &Service{repo: repo} }
30
+
31
+ func (s *Service) PermissionsOf(ctx context.Context, roleCode string) (map[string]struct{}, error) {
32
+ codes, err := s.repo.PermissionCodes(ctx, roleCode)
33
+ if err != nil {
34
+ return nil, err
35
+ }
36
+ set := make(map[string]struct{}, len(codes))
37
+ for _, code := range codes {
38
+ set[code] = struct{}{}
39
+ }
40
+ return set, nil
41
+ }
42
+
43
+ func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
44
+ if _, err := s.repo.FindByCode(ctx, code); err != nil {
45
+ if errors.Is(err, domain.ErrNotFound) {
46
+ return false, nil
47
+ }
48
+ return false, err
49
+ }
50
+ return true, nil
51
+ }
52
+
53
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]RoleListItem, error) {
54
+ roles, err := s.repo.FindAll(ctx, limit, offset)
55
+ if err != nil {
56
+ return nil, err
57
+ }
58
+ out := make([]RoleListItem, len(roles))
59
+ for i, role := range roles {
60
+ perms, err := s.repo.PermissionCodes(ctx, role.Code)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ out[i] = RoleListItem{Role: role, Permissions: perms}
65
+ }
66
+ return out, nil
67
+ }
68
+
69
+ var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
70
+
71
+ func (s *Service) Create(ctx context.Context, in CreateInput) (*RoleListItem, error) {
72
+ if !roleCodePattern.MatchString(in.Code) {
73
+ return nil, errInvalidCode()
74
+ }
75
+ role := &domain.Role{Code: in.Code, Name: in.Name}
76
+ if err := s.repo.Create(ctx, role); err != nil {
77
+ return nil, err
78
+ }
79
+ return &RoleListItem{Role: *role, Permissions: []string{}}, nil
80
+ }
81
+
82
+ func (s *Service) SetPermissions(ctx context.Context, code string, permissionCodes []string) (*RoleListItem, error) {
83
+ role, err := s.repo.FindByCode(ctx, code)
84
+ if err != nil {
85
+ return nil, wrapFindErr(err)
86
+ }
87
+ if err := s.validatePermissionCodes(ctx, permissionCodes); err != nil {
88
+ return nil, err
89
+ }
90
+ if !slices.Contains(permissionCodes, PermRoleManage) {
91
+ isLast, err := s.isLastRoleManager(ctx, code)
92
+ if err != nil {
93
+ return nil, err
94
+ }
95
+ if isLast {
96
+ return nil, errLastRoleManager()
97
+ }
98
+ }
99
+ if err := s.repo.SetPermissions(ctx, code, permissionCodes); err != nil {
100
+ return nil, err
101
+ }
102
+ return &RoleListItem{Role: *role, Permissions: permissionCodes}, nil
103
+ }
104
+
105
+ func (s *Service) validatePermissionCodes(ctx context.Context, codes []string) error {
106
+ all, err := s.repo.AllPermissionCodes(ctx)
107
+ if err != nil {
108
+ return err
109
+ }
110
+ valid := make(map[string]struct{}, len(all))
111
+ for _, code := range all {
112
+ valid[code] = struct{}{}
113
+ }
114
+ for _, code := range codes {
115
+ if _, ok := valid[code]; !ok {
116
+ return errUnknownPermission()
117
+ }
118
+ }
119
+ return nil
120
+ }
121
+
122
+ func (s *Service) Delete(ctx context.Context, code string) error {
123
+ role, err := s.repo.FindByCode(ctx, code)
124
+ if err != nil {
125
+ return wrapFindErr(err)
126
+ }
127
+ if role.IsSystem {
128
+ return errSystemRole()
129
+ }
130
+ isLast, err := s.isLastRoleManager(ctx, code)
131
+ if err != nil {
132
+ return err
133
+ }
134
+ if isLast {
135
+ return errLastRoleManager()
136
+ }
137
+ return s.repo.Delete(ctx, code)
138
+ }
139
+
140
+ func (s *Service) isLastRoleManager(ctx context.Context, code string) (bool, error) {
141
+ others, err := s.repo.CountRolesWithPermission(ctx, PermRoleManage, code)
142
+ if err != nil {
143
+ return false, err
144
+ }
145
+ return others == 0, nil
146
+ }
147
+
148
+ func (s *Service) ListPermissions(ctx context.Context, limit, offset int) ([]domain.Permission, error) {
149
+ return s.repo.FindAllPermissions(ctx, limit, offset)
150
+ }
151
+
152
+ func wrapFindErr(err error) error {
153
+ if errors.Is(err, domain.ErrNotFound) {
154
+ return domain.ErrNotFound
155
+ }
156
+ return err
157
+ }
@@ -1,4 +1,4 @@
1
- package role
1
+ package application
2
2
 
3
3
  import (
4
4
  "context"
@@ -6,8 +6,7 @@ import (
6
6
  "net/http"
7
7
  "testing"
8
8
 
9
- "{{goModule}}/internal/app/role/model"
10
- "{{goModule}}/internal/shared/apperror"
9
+ "{{goModule}}/internal/app/role/domain"
11
10
  )
12
11
 
13
12
  // fakeRepo = mock of the repository interface, so the service can be tested
@@ -15,7 +14,7 @@ import (
15
14
  // than the one being edited currently grant PermRoleManage", exactly what
16
15
  // CountRolesWithPermission's excludeCode param means.
17
16
  type fakeRepo struct {
18
- role *model.Role
17
+ role *domain.Role
19
18
  findErr error
20
19
  allPermissionCodes []string
21
20
  countWithPermission int64
@@ -24,38 +23,46 @@ type fakeRepo struct {
24
23
  }
25
24
 
26
25
  func (f *fakeRepo) PermissionCodes(context.Context, string) ([]string, error) { return nil, nil }
27
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.Role, error) { return nil, nil }
28
- func (f *fakeRepo) FindByCode(context.Context, string) (*model.Role, error) {
26
+ func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.Role, error) { return nil, nil }
27
+ func (f *fakeRepo) FindByCode(context.Context, string) (*domain.Role, error) {
29
28
  if f.findErr != nil {
30
29
  return nil, f.findErr
31
30
  }
32
31
  return f.role, nil
33
32
  }
34
- func (f *fakeRepo) Create(context.Context, *model.Role) error { return nil }
33
+ func (f *fakeRepo) Create(context.Context, *domain.Role) error { return nil }
35
34
  func (f *fakeRepo) SetPermissions(context.Context, string, []string) error {
36
35
  return f.setPermissionsErr
37
36
  }
38
37
  func (f *fakeRepo) Delete(context.Context, string) error { return f.deleteErr }
39
- func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]model.Permission, error) {
38
+ func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]domain.Permission, error) {
40
39
  return nil, nil
41
40
  }
42
- func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) { return f.allPermissionCodes, nil }
41
+ func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) {
42
+ return f.allPermissionCodes, nil
43
+ }
43
44
  func (f *fakeRepo) CountRolesWithPermission(context.Context, string, string) (int64, error) {
44
45
  return f.countWithPermission, nil
45
46
  }
46
47
 
47
48
  func status(t *testing.T, err error) int {
48
49
  t.Helper()
49
- var appErr *apperror.AppError
50
- if !errors.As(err, &appErr) {
51
- t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
50
+ if errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrSystemRole) || errors.Is(err, domain.ErrLastRoleManager) {
51
+ return http.StatusConflict
52
+ }
53
+ if errors.Is(err, domain.ErrInvalidCode) || errors.Is(err, domain.ErrUnknownPermission) {
54
+ return http.StatusUnprocessableEntity
55
+ }
56
+ if errors.Is(err, domain.ErrNotFound) {
57
+ return http.StatusNotFound
52
58
  }
53
- return appErr.HTTPStatus
59
+ t.Fatalf("unexpected role error %T: %v", err, err)
60
+ return 0
54
61
  }
55
62
 
56
63
  func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
57
64
  repo := &fakeRepo{
58
- role: &model.Role{Code: "ops", Name: "Ops"},
65
+ role: &domain.Role{Code: "ops", Name: "Ops"},
59
66
  allPermissionCodes: []string{PermRoleManage, "user:read"},
60
67
  countWithPermission: 0, // no OTHER role grants PermRoleManage
61
68
  }
@@ -69,7 +76,7 @@ func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
69
76
 
70
77
  func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T) {
71
78
  repo := &fakeRepo{
72
- role: &model.Role{Code: "ops", Name: "Ops"},
79
+ role: &domain.Role{Code: "ops", Name: "Ops"},
73
80
  allPermissionCodes: []string{PermRoleManage, "user:read"},
74
81
  countWithPermission: 1, // another role still grants PermRoleManage
75
82
  }
@@ -81,7 +88,7 @@ func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T)
81
88
 
82
89
  func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
83
90
  repo := &fakeRepo{
84
- role: &model.Role{Code: "ops", Name: "Ops"},
91
+ role: &domain.Role{Code: "ops", Name: "Ops"},
85
92
  allPermissionCodes: []string{PermRoleManage},
86
93
  countWithPermission: 0,
87
94
  }
@@ -93,7 +100,7 @@ func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
93
100
  }
94
101
 
95
102
  func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
96
- repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 0}
103
+ repo := &fakeRepo{role: &domain.Role{Code: "ops"}, countWithPermission: 0}
97
104
  svc := NewService(repo)
98
105
  err := svc.Delete(context.Background(), "ops")
99
106
  if got := status(t, err); got != http.StatusConflict {
@@ -102,7 +109,7 @@ func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
102
109
  }
103
110
 
104
111
  func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
105
- repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 1}
112
+ repo := &fakeRepo{role: &domain.Role{Code: "ops"}, countWithPermission: 1}
106
113
  svc := NewService(repo)
107
114
  if err := svc.Delete(context.Background(), "ops"); err != nil {
108
115
  t.Fatalf("expected delete to succeed when another role still manages roles, got %v", err)
@@ -110,7 +117,7 @@ func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
110
117
  }
111
118
 
112
119
  func TestService_Delete_SystemRoleRejected(t *testing.T) {
113
- repo := &fakeRepo{role: &model.Role{Code: "admin", IsSystem: true}}
120
+ repo := &fakeRepo{role: &domain.Role{Code: "admin", IsSystem: true}}
114
121
  svc := NewService(repo)
115
122
  err := svc.Delete(context.Background(), "admin")
116
123
  if got := status(t, err); got != http.StatusConflict {
@@ -0,0 +1,48 @@
1
+ package role
2
+
3
+ import (
4
+ "time"
5
+
6
+ httpadapter "{{goModule}}/internal/app/role/adapters/inbound/http"
7
+ "{{goModule}}/internal/app/role/adapters/outbound/postgres"
8
+ "{{goModule}}/internal/app/role/application"
9
+ "{{goModule}}/internal/app/role/domain"
10
+ "{{goModule}}/internal/app/role/ports"
11
+ "{{goModule}}/internal/shared/middleware"
12
+
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ // Composition is the only public graph exposed to cmd/api. The feature root
17
+ // owns the role repository, application service, permission cache, and HTTP
18
+ // adapter; process wiring does not construct any of those internals.
19
+ type Composition struct {
20
+ Service *application.Service
21
+ Authz *middleware.Authz
22
+ Handler *httpadapter.Handler
23
+ }
24
+
25
+ type Service = application.Service
26
+ type ServicePort = application.ServicePort
27
+ type Repository = postgres.Repository
28
+ type CreateInput = application.CreateInput
29
+ type SetPermissionsInput = application.SetPermissionsInput
30
+ type Role = domain.Role
31
+ type Permission = domain.Permission
32
+ type AuthorizationPort = ports.AuthorizationPort
33
+
34
+ const PermRoleManage = application.PermRoleManage
35
+
36
+ func NewServiceFromDB(db *gorm.DB) *Service {
37
+ return application.NewService(postgres.NewRepository(db))
38
+ }
39
+
40
+ func NewCompositionFromDB(db *gorm.DB, jwtSecret string, cacheTTL time.Duration) *Composition {
41
+ service := NewServiceFromDB(db)
42
+ authz := middleware.NewAuthz(service.PermissionsOf, cacheTTL)
43
+ return &Composition{
44
+ Service: service,
45
+ Authz: authz,
46
+ Handler: httpadapter.NewHandler(service, jwtSecret, authz),
47
+ }
48
+ }
@@ -0,0 +1,23 @@
1
+ // Package domain contains role and permission state without GORM or HTTP
2
+ // concerns.
3
+ package domain
4
+
5
+ import "time"
6
+
7
+ type Role struct {
8
+ Code string
9
+ Name string
10
+ IsSystem bool
11
+ CreatedAt time.Time
12
+ UpdatedAt time.Time
13
+ }
14
+
15
+ type Permission struct {
16
+ Code string
17
+ Description string
18
+ }
19
+
20
+ type RolePermission struct {
21
+ RoleCode string
22
+ PermissionCode string
23
+ }
@@ -0,0 +1,26 @@
1
+ package domain
2
+
3
+ import "errors"
4
+
5
+ var (
6
+ ErrNotFound = errors.New("role not found")
7
+ ErrConflict = errors.New("role conflict")
8
+ ErrHasReferences = errors.New("role has references")
9
+ ErrInvalidCode = errors.New("role code is invalid")
10
+ ErrSystemRole = errors.New("system role cannot be changed")
11
+ ErrUnknownPermission = errors.New("permission does not exist")
12
+ ErrLastRoleManager = errors.New("last role manager cannot be removed")
13
+ )
14
+
15
+ type RuleError struct {
16
+ Code string
17
+ Message string
18
+ Cause error
19
+ }
20
+
21
+ func (e *RuleError) Error() string { return e.Message }
22
+ func (e *RuleError) Unwrap() error { return e.Cause }
23
+
24
+ func Rule(code, message string, cause error) error {
25
+ return &RuleError{Code: code, Message: message, Cause: cause}
26
+ }
@@ -0,0 +1,25 @@
1
+ // Package ports contains role's application-owned repository contracts.
2
+ package ports
3
+
4
+ import (
5
+ "context"
6
+
7
+ "{{goModule}}/internal/app/role/domain"
8
+ )
9
+
10
+ type Repository interface {
11
+ PermissionCodes(context.Context, string) ([]string, error)
12
+ FindAll(context.Context, int, int) ([]domain.Role, error)
13
+ FindByCode(context.Context, string) (*domain.Role, error)
14
+ Create(context.Context, *domain.Role) error
15
+ SetPermissions(context.Context, string, []string) error
16
+ Delete(context.Context, string) error
17
+ FindAllPermissions(context.Context, int, int) ([]domain.Permission, error)
18
+ AllPermissionCodes(context.Context) ([]string, error)
19
+ CountRolesWithPermission(context.Context, string, string) (int64, error)
20
+ }
21
+
22
+ type AuthorizationPort interface {
23
+ PermissionsOf(context.Context, string) (map[string]struct{}, error)
24
+ CodeExists(context.Context, string) (bool, error)
25
+ }
@@ -1,14 +1,6 @@
1
- -- if an admin created a custom role after this migration and it's still
2
- -- assigned to any user, dropping the roles table would orphan that value —
3
- -- raise a clear, actionable error instead of a raw FK-violation.
4
- DO $$
5
- BEGIN
6
- IF EXISTS (SELECT 1 FROM user_svc.users WHERE role NOT IN ('staff', 'admin')) THEN
7
- RAISE EXCEPTION 'cannot roll back add_roles: users still have a role other than staff/admin — reassign them to staff/admin first';
8
- END IF;
9
- END $$;
10
-
11
- ALTER TABLE user_svc.users DROP COLUMN role;
1
+ -- Auth owns users.role, so RBAC removes only the constraint it added. The
2
+ -- column and existing assignments remain valid for auth-only operation.
3
+ ALTER TABLE user_svc.users DROP CONSTRAINT IF EXISTS users_role_fkey;
12
4
 
13
5
  DROP TABLE role_svc.role_permissions;
14
6
  DROP TABLE role_svc.permissions;
@@ -33,9 +33,20 @@ INSERT INTO role_svc.permissions (code, description) VALUES
33
33
  INSERT INTO role_svc.role_permissions (role_code, permission_code)
34
34
  SELECT 'admin', code FROM role_svc.permissions;
35
35
 
36
- -- cross-schema FK: role_svc's migration is the one place allowed to alter
37
- -- user_svc's table, because this feature's whole point is "give users a
38
- -- role" see docs/architect/patterns.md's FK rules for why a plain FK, not
39
- -- an app-level existence check, is the right way to enforce this.
40
- ALTER TABLE user_svc.users
41
- ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES role_svc.roles(code);
36
+ -- Auth owns the stable users.role column. RBAC owns the role catalog and adds
37
+ -- only the cross-schema constraint after that catalog exists. This keeps each
38
+ -- migration aligned with its module instead of making RBAC recreate auth's
39
+ -- persistence shape.
40
+ DO $$
41
+ BEGIN
42
+ IF NOT EXISTS (
43
+ SELECT 1
44
+ FROM pg_constraint
45
+ WHERE conname = 'users_role_fkey'
46
+ AND conrelid = 'user_svc.users'::regclass
47
+ ) THEN
48
+ ALTER TABLE user_svc.users
49
+ ADD CONSTRAINT users_role_fkey
50
+ FOREIGN KEY (role) REFERENCES role_svc.roles(code);
51
+ END IF;
52
+ END $$;
@@ -0,0 +1,84 @@
1
+ package queue
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/google/uuid"
11
+ "gorm.io/driver/postgres"
12
+ "gorm.io/gorm"
13
+ )
14
+
15
+ type riverRoundTripJob struct {
16
+ Marker string `json:"marker"`
17
+ }
18
+
19
+ func (riverRoundTripJob) Kind() string { return "queue-test-round-trip" }
20
+
21
+ // TestRiverEnqueueAndWorkerProcess proves the Postgres queue path beyond
22
+ // client construction: a job is written, picked up by the worker adapter,
23
+ // decoded, and handed to the registered backend-neutral handler.
24
+ func TestRiverEnqueueAndWorkerProcess(t *testing.T) {
25
+ dsn := os.Getenv("TEST_DB_DSN")
26
+ if dsn == "" {
27
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
28
+ t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
29
+ }
30
+ t.Skip("River integration test skipped: set TEST_DB_DSN to a database with River migrations")
31
+ }
32
+
33
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
34
+ if err != nil {
35
+ t.Fatalf("open test database: %v", err)
36
+ }
37
+ sqlDB, err := db.DB()
38
+ if err != nil {
39
+ t.Fatalf("get test database handle: %v", err)
40
+ }
41
+ t.Cleanup(func() { _ = sqlDB.Close() })
42
+
43
+ worker, err := NewRiverWorker(db, 1)
44
+ if err != nil {
45
+ t.Fatalf("create River worker: %v", err)
46
+ }
47
+
48
+ received := make(chan string, 1)
49
+ worker.Handle((riverRoundTripJob{}).Kind(), func(_ context.Context, raw []byte) error {
50
+ var job riverRoundTripJob
51
+ if err := json.Unmarshal(raw, &job); err != nil {
52
+ return err
53
+ }
54
+ received <- job.Marker
55
+ return nil
56
+ })
57
+
58
+ workerCtx, cancel := context.WithCancel(context.Background())
59
+ t.Cleanup(func() {
60
+ cancel()
61
+ stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
62
+ defer stopCancel()
63
+ if err := worker.Stop(stopCtx); err != nil {
64
+ t.Errorf("stop River worker: %v", err)
65
+ }
66
+ })
67
+ if err := worker.Start(workerCtx); err != nil {
68
+ t.Fatalf("start River worker: %v", err)
69
+ }
70
+
71
+ expected := uuid.NewString()
72
+ if err := worker.Enqueue(context.Background(), riverRoundTripJob{Marker: expected}, nil); err != nil {
73
+ t.Fatalf("enqueue River job: %v", err)
74
+ }
75
+
76
+ select {
77
+ case actual := <-received:
78
+ if actual != expected {
79
+ t.Fatalf("worker received marker %q, want %q", actual, expected)
80
+ }
81
+ case <-time.After(10 * time.Second):
82
+ t.Fatal("River worker did not process the enqueued job within 10s")
83
+ }
84
+ }