@nakedev/go-scaffold 0.4.3 → 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 (164) hide show
  1. package/README.md +567 -513
  2. package/dist/commands/auth.js +12 -1
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/create.js +2 -1
  5. package/dist/commands/generate.js +5 -2
  6. package/dist/commands/method.js +66 -70
  7. package/dist/commands/observability.js +4 -53
  8. package/dist/commands/rbac.js +19 -8
  9. package/dist/commands/undo.js +6 -3
  10. package/dist/commands/worker.js +14 -4
  11. package/dist/index.js +13 -1
  12. package/dist/templates/auth-manifest.js +46 -45
  13. package/dist/templates/create-manifest.js +4 -0
  14. package/dist/templates/module-manifest.js +82 -60
  15. package/dist/templates/rbac-manifest.js +15 -11
  16. package/dist/templates/worker-manifest.js +4 -1
  17. package/dist/types.js +2 -0
  18. package/dist/utils/auth-patcher.js +22 -22
  19. package/dist/utils/config.js +24 -5
  20. package/dist/utils/docs-patcher.js +68 -0
  21. package/dist/utils/hexagonal-method-patcher.js +334 -0
  22. package/dist/utils/main-patcher.js +3 -3
  23. package/dist/utils/module-location.js +17 -11
  24. package/dist/utils/platform-patcher.js +27 -0
  25. package/dist/utils/rbac-patcher.js +73 -216
  26. package/package.json +1 -1
  27. package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
  28. package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
  29. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  30. package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
  31. package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
  32. package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
  33. package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
  34. package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
  35. package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
  36. package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
  37. package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
  38. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  39. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  40. package/templates/add/auth/internal/app/user/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
  41. package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
  42. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  43. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  44. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  45. package/templates/add/auth/internal/app/user/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
  46. package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
  47. package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
  48. package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
  49. package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
  50. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  51. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  52. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  53. package/templates/add/auth/internal/app/user/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
  54. package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
  55. package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
  56. package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
  57. package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
  58. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
  59. package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
  60. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
  61. package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
  62. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  63. package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
  64. package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
  65. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  66. package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
  67. package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
  68. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  69. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  70. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  71. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  72. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +1 -1
  73. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  74. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
  75. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  76. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  77. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  78. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  79. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  80. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  81. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  82. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  83. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  84. package/templates/add/rbac/internal/app/role/composition.go.hbs +23 -10
  85. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  86. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  87. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  88. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  89. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  90. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  91. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +54 -36
  92. package/templates/create/base/.golangci.yml.hbs +2 -2
  93. package/templates/create/base/AGENTS.md.hbs +55 -31
  94. package/templates/create/base/README.md.hbs +77 -22
  95. package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
  96. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  97. package/templates/create/features/docs/architecture.md.hbs +64 -26
  98. package/templates/create/features/docs/patterns.md.hbs +117 -90
  99. package/templates/create/features/docs/techstack.md.hbs +17 -2
  100. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  101. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  102. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  103. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  104. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  105. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  106. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  107. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  108. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  109. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  110. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  111. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  112. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  113. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  114. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  115. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  116. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  117. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  118. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  119. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  120. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  121. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  122. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  123. package/templates/generate/module/migration.up.sql.hbs +1 -1
  124. package/dist/utils/method-patcher.js +0 -537
  125. package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
  126. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
  127. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
  128. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
  129. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
  130. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  131. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
  132. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
  133. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
  134. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
  135. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
  136. package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
  137. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
  138. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  139. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  140. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  141. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  142. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  143. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  144. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  145. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  146. package/templates/generate/module/commands.go.hbs +0 -95
  147. package/templates/generate/module/composition.go.hbs +0 -23
  148. package/templates/generate/module/cqrs_test.go.hbs +0 -7
  149. package/templates/generate/module/dto.go.hbs +0 -36
  150. package/templates/generate/module/errors.go.hbs +0 -33
  151. package/templates/generate/module/handler.go.hbs +0 -179
  152. package/templates/generate/module/handler_test.go.hbs +0 -174
  153. package/templates/generate/module/minimal/commands.go.hbs +0 -34
  154. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  155. package/templates/generate/module/minimal/handler.go.hbs +0 -82
  156. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  157. package/templates/generate/module/minimal/queries.go.hbs +0 -45
  158. package/templates/generate/module/minimal/service.go.hbs +0 -71
  159. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  160. package/templates/generate/module/model/model.go.hbs +0 -36
  161. package/templates/generate/module/queries.go.hbs +0 -62
  162. package/templates/generate/module/repository.go.hbs +0 -103
  163. package/templates/generate/module/service.go.hbs +0 -164
  164. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -1,58 +0,0 @@
1
- package user
2
-
3
- import (
4
- "context"
5
- "time"
6
-
7
- "{{goModule}}/internal/app/user/application"
8
-
9
- "github.com/google/uuid"
10
- )
11
-
12
- // refreshTokenStore is the session surface needed by the refresh/logout use
13
- // cases. Keeping this port narrow means a store for OAuth state or recovery
14
- // tokens cannot accidentally become a dependency of session logic.
15
- type RefreshTokenStore interface {
16
- SetRefreshToken(ctx context.Context, tokenHash string, token refreshTokenRecord) error
17
- GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
18
- ConsumeRefreshToken(ctx context.Context, tokenHash string) (refreshTokenRecord, bool, error)
19
- DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error
20
- RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error
21
- IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
22
- }
23
-
24
- // oauthTransactionStore is the one-time authorization attempt surface. It is
25
- // intentionally separate from refresh sessions: the two have different TTL,
26
- // replay, and operational characteristics.
27
- type OAuthTransactionStore interface {
28
- SetLoginTransaction(ctx context.Context, stateHash string, transaction loginTransaction) error
29
- ConsumeLoginTransaction(ctx context.Context, stateHash string) (loginTransaction, bool, error)
30
- }
31
-
32
- // recoveryTokenPort is the durable one-time-token surface. Recovery owns the
33
- // transaction boundary that consumes a token and updates the user, while the
34
- // outer service also needs the two write methods to issue links.
35
- type RecoveryTokenStore interface {
36
- application.RecoveryTokens
37
- SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
38
- SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
39
- }
40
-
41
- // refreshTokenRecord carries both the sliding inactivity expiry and the fixed
42
- // absolute expiry. A rotation may move ExpiresAt forward, but it must never
43
- // move AbsoluteExpiresAt forward.
44
- type refreshTokenRecord struct {
45
- UserID uuid.UUID `json:"user_id"`
46
- ExpiresAt time.Time `json:"expires_at"`
47
- AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
48
- }
49
-
50
- // loginTransaction is the server-owned binding for a browser authorization
51
- // attempt. State itself is only hashed before storage; the provider receives
52
- // the original value so the frontend callback can compare it unchanged.
53
- type loginTransaction struct {
54
- Provider string
55
- CodeChallenge string
56
- Nonce string
57
- ExpiresAt time.Time
58
- }
@@ -1,45 +0,0 @@
1
- package role
2
-
3
- import (
4
- "time"
5
-
6
- "{{goModule}}/internal/app/role/model"
7
- )
8
-
9
- // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
10
- type response struct {
11
- Code string `json:"code"`
12
- Name string `json:"name"`
13
- IsSystem bool `json:"is_system"`
14
- Permissions []string `json:"permissions"`
15
- CreatedAt time.Time `json:"created_at"`
16
- }
17
-
18
- func toResponse(m *model.Role, perms []string) response {
19
- return response{Code: m.Code, Name: m.Name, IsSystem: m.IsSystem, Permissions: perms, CreatedAt: m.CreatedAt}
20
- }
21
-
22
- type CreateInput struct {
23
- Code string `json:"code" binding:"required,max=20"`
24
- Name string `json:"name" binding:"required"`
25
- }
26
-
27
- // SetPermissionsInput.PermissionCodes is a *pointer* so that "field absent"
28
- // and "field present but empty" stay different things. An empty list is a
29
- // valid request — it revokes every permission the role had — but with a plain
30
- // []string a body that simply misspells the key (say `{"permissions": [...]}`)
31
- // binds to nil, reads as that same "revoke everything", and answers 200 having
32
- // silently stripped the role. `required` on the pointer rejects the absent
33
- // case with a 400 while still allowing an explicit [].
34
- type SetPermissionsInput struct {
35
- PermissionCodes *[]string `json:"permission_codes" binding:"required"`
36
- }
37
-
38
- type permissionResponse struct {
39
- Code string `json:"code"`
40
- Description string `json:"description"`
41
- }
42
-
43
- func toPermissionResponse(m *model.Permission) permissionResponse {
44
- return permissionResponse{Code: m.Code, Description: m.Description}
45
- }
@@ -1,39 +0,0 @@
1
- package role
2
-
3
- import (
4
- "net/http"
5
-
6
- "{{goModule}}/internal/shared/apperror"
7
- )
8
-
9
- // error catalog specific to role — codes carry more meaning than the generic apperror ones
10
- // functions, not vars: the error middleware writes RequestID onto the returned pointer directly
11
- // (a shared instance would race across concurrent requests).
12
-
13
- func errNotFound() *apperror.AppError {
14
- return apperror.New(http.StatusNotFound, "ROLE_NOT_FOUND", "role not found")
15
- }
16
-
17
- func errConflict() *apperror.AppError {
18
- return apperror.New(http.StatusConflict, "ROLE_CONFLICT", "role code already exists")
19
- }
20
-
21
- func errHasReferences() *apperror.AppError {
22
- return apperror.New(http.StatusConflict, "ROLE_HAS_REFERENCES", "role still has users assigned to it")
23
- }
24
-
25
- func errInvalidCode() *apperror.AppError {
26
- return apperror.New(http.StatusUnprocessableEntity, "ROLE_INVALID_CODE", "code must start with a lowercase letter and contain only lowercase letters, digits, or underscores")
27
- }
28
-
29
- func errSystemRole() *apperror.AppError {
30
- return apperror.New(http.StatusConflict, "ROLE_IS_SYSTEM", "staff/admin are system roles and cannot be deleted")
31
- }
32
-
33
- func errUnknownPermission() *apperror.AppError {
34
- return apperror.New(http.StatusUnprocessableEntity, "ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist")
35
- }
36
-
37
- func errLastRoleManager() *apperror.AppError {
38
- return apperror.New(http.StatusConflict, "ROLE_LAST_MANAGER", "this is the only role that can manage roles/permissions — grant "+PermRoleManage+" to another role first")
39
- }
@@ -1,104 +0,0 @@
1
- package role
2
-
3
- import (
4
- "net/http"
5
-
6
- "{{goModule}}/internal/shared/httpx"
7
- "{{goModule}}/internal/shared/middleware"
8
- "{{goModule}}/internal/shared/pagination"
9
-
10
- "github.com/gin-gonic/gin"
11
- )
12
-
13
- // PermRoleManage gates every endpoint in this file — role/permission
14
- // management is a single admin capability, not split per-action.
15
- const PermRoleManage = "role:manage"
16
-
17
- type Handler struct {
18
- svc *Service
19
- jwtSecret string
20
- authz *middleware.Authz
21
- }
22
-
23
- func NewHandler(svc *Service, jwtSecret string, authz *middleware.Authz) *Handler {
24
- return &Handler{svc: svc, jwtSecret: jwtSecret, authz: authz}
25
- }
26
-
27
- // Register wires role's routes onto the router group (takes an IRouter so it can be nested under /v1)
28
- func (h *Handler) Register(rg gin.IRouter) {
29
- roles := rg.Group("/roles", middleware.RequireAuth(h.jwtSecret), h.authz.Require(PermRoleManage))
30
- roles.GET("", h.list)
31
- roles.POST("", h.create)
32
- roles.PATCH("/:code/permissions", h.setPermissions)
33
- roles.DELETE("/:code", h.delete)
34
-
35
- perms := rg.Group("/permissions", middleware.RequireAuth(h.jwtSecret), h.authz.Require(PermRoleManage))
36
- perms.GET("", h.listPermissions)
37
- }
38
-
39
- func (h *Handler) list(c *gin.Context) {
40
- p := pagination.Parse(c)
41
- items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
42
- if err != nil {
43
- c.Error(err)
44
- return
45
- }
46
- out := make([]response, len(items))
47
- for i := range items {
48
- out[i] = toResponse(&items[i].Role, items[i].Permissions)
49
- }
50
- c.JSON(http.StatusOK, p.Response(out))
51
- }
52
-
53
- func (h *Handler) create(c *gin.Context) {
54
- var in CreateInput
55
- if err := c.ShouldBindJSON(&in); err != nil {
56
- c.Error(httpx.BindErr(err))
57
- return
58
- }
59
- item, err := h.svc.Create(c.Request.Context(), in)
60
- if err != nil {
61
- c.Error(err)
62
- return
63
- }
64
- c.JSON(http.StatusCreated, toResponse(&item.Role, item.Permissions))
65
- }
66
-
67
- func (h *Handler) setPermissions(c *gin.Context) {
68
- var in SetPermissionsInput
69
- if err := c.ShouldBindJSON(&in); err != nil {
70
- c.Error(httpx.BindErr(err))
71
- return
72
- }
73
- item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
74
- if err != nil {
75
- c.Error(err)
76
- return
77
- }
78
- // the grant is useless until the cache that answers Require() knows about it
79
- h.authz.Invalidate(item.Code)
80
- c.JSON(http.StatusOK, toResponse(&item.Role, item.Permissions))
81
- }
82
-
83
- func (h *Handler) delete(c *gin.Context) {
84
- if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
85
- c.Error(err)
86
- return
87
- }
88
- h.authz.Invalidate(c.Param("code"))
89
- c.Status(http.StatusNoContent)
90
- }
91
-
92
- func (h *Handler) listPermissions(c *gin.Context) {
93
- p := pagination.Parse(c)
94
- items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
95
- if err != nil {
96
- c.Error(err)
97
- return
98
- }
99
- out := make([]permissionResponse, len(items))
100
- for i := range items {
101
- out[i] = toPermissionResponse(&items[i])
102
- }
103
- c.JSON(http.StatusOK, p.Response(out))
104
- }
@@ -1,12 +0,0 @@
1
- package model
2
-
3
- // Permission = an action middleware.Authz.Require checks for. Rows are
4
- // seeded by migration, not admin-created — a permission with no matching
5
- // Require() call in the code would do nothing.
6
- type Permission struct {
7
- Code string `json:"code" gorm:"type:varchar(50);primaryKey"`
8
- Description string `json:"description" gorm:"not null"`
9
- }
10
-
11
- // TableName — see Role.TableName; same schema, same reasoning.
12
- func (Permission) TableName() string { return "role_svc.permissions" }
@@ -1,22 +0,0 @@
1
- // Package model holds this domain's GORM tables — a folder (not a single
2
- // file) so a domain with more than one table can add a file per table
3
- // without cluttering the domain package's top level.
4
- package model
5
-
6
- import "time"
7
-
8
- // Role = an assignable permission level, backed by a real table instead of a
9
- // hardcoded enum — adding one is a row insert, not a migration + redeploy.
10
- // Code is the primary key (not a generated UUID): users.role already stores
11
- // this string, so using it as the join key needs zero data migration.
12
- type Role struct {
13
- Code string `json:"code" gorm:"type:varchar(20);primaryKey"`
14
- Name string `json:"name" gorm:"not null"`
15
- IsSystem bool `json:"is_system" gorm:"not null;default:false"`
16
- CreatedAt time.Time `json:"created_at"`
17
- UpdatedAt time.Time `json:"updated_at"`
18
- }
19
-
20
- // TableName pins this to role_svc rather than GORM's default inflection —
21
- // every domain gets its own schema, see docs/architect/patterns.md.
22
- func (Role) TableName() string { return "role_svc.roles" }
@@ -1,11 +0,0 @@
1
- package model
2
-
3
- // RolePermission = the grant between Role and Permission — the one thing
4
- // admins actually edit; Role and Permission rows themselves stay fixed.
5
- type RolePermission struct {
6
- RoleCode string `json:"role_code" gorm:"primaryKey;type:varchar(20)"`
7
- PermissionCode string `json:"permission_code" gorm:"primaryKey;type:varchar(50)"`
8
- }
9
-
10
- // TableName — see Role.TableName; same schema, same reasoning.
11
- func (RolePermission) TableName() string { return "role_svc.role_permissions" }
@@ -1,97 +0,0 @@
1
- package role
2
-
3
- import (
4
- "context"
5
-
6
- "{{goModule}}/internal/app/role/model"
7
- "{{goModule}}/internal/shared/tx"
8
-
9
- "gorm.io/gorm"
10
- )
11
-
12
- // Repository = data access for role (the only place that touches the DB for this domain)
13
- // every method takes ctx, so a cancelled request cancels the query too
14
- type Repository struct {
15
- db *gorm.DB
16
- }
17
-
18
- func NewRepository(db *gorm.DB) *Repository {
19
- return &Repository{db: db}
20
- }
21
-
22
- // PermissionCodes returns the permission codes granted to roleCode.
23
- func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
24
- var codes []string
25
- err := tx.From(ctx, r.db).WithContext(ctx).
26
- Model(&model.RolePermission{}).
27
- Where("role_code = ?", roleCode).
28
- Pluck("permission_code", &codes).Error
29
- return codes, err
30
- }
31
-
32
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.Role, error) {
33
- var items []model.Role
34
- err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
35
- return items, err
36
- }
37
-
38
- func (r *Repository) FindByCode(ctx context.Context, code string) (*model.Role, error) {
39
- var m model.Role
40
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
41
- return nil, err
42
- }
43
- return &m, nil
44
- }
45
-
46
- func (r *Repository) Create(ctx context.Context, m *model.Role) error {
47
- return tx.From(ctx, r.db).WithContext(ctx).Create(m).Error
48
- }
49
-
50
- // SetPermissions replaces roleCode's entire permission grant set in one
51
- // transaction — simpler for a checkbox-style admin UI than diffing
52
- // add/remove, and cheap at this scale (a handful of rows per role).
53
- func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
54
- return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
55
- if err := t.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
56
- return err
57
- }
58
- if len(permissionCodes) == 0 {
59
- return nil
60
- }
61
- grants := make([]model.RolePermission, len(permissionCodes))
62
- for i, code := range permissionCodes {
63
- grants[i] = model.RolePermission{RoleCode: roleCode, PermissionCode: code}
64
- }
65
- return t.Create(&grants).Error
66
- })
67
- }
68
-
69
- func (r *Repository) Delete(ctx context.Context, code string) error {
70
- return tx.From(ctx, r.db).WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
71
- }
72
-
73
- func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
74
- var items []model.Permission
75
- err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
76
- return items, err
77
- }
78
-
79
- // AllPermissionCodes returns every permission code that exists, unpaginated
80
- // — the catalog is a handful of rows, cheap to fetch whole for validating a
81
- // SetPermissions request.
82
- func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
83
- var codes []string
84
- err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
85
- return codes, err
86
- }
87
-
88
- // CountRolesWithPermission counts roles other than excludeCode that grant
89
- // permissionCode — used to check whether editing or deleting excludeCode
90
- // would leave no role holding permissionCode at all.
91
- func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
92
- var count int64
93
- err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.RolePermission{}).
94
- Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).
95
- Count(&count).Error
96
- return count, err
97
- }
@@ -1,217 +0,0 @@
1
- package role
2
-
3
- import (
4
- "context"
5
- "errors"
6
- "regexp"
7
- "slices"
8
-
9
- "{{goModule}}/internal/app/role/model"
10
- "{{goModule}}/internal/shared/apperror"
11
- "{{goModule}}/internal/shared/dberr"
12
-
13
- "gorm.io/gorm"
14
- )
15
-
16
- // repository = what the service needs from the data layer (declared on the
17
- // consumer side, so it can be faked in tests)
18
- type repository interface {
19
- PermissionCodes(ctx context.Context, roleCode string) ([]string, error)
20
- FindAll(ctx context.Context, limit, offset int) ([]model.Role, error)
21
- FindByCode(ctx context.Context, code string) (*model.Role, error)
22
- Create(ctx context.Context, m *model.Role) error
23
- SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error
24
- Delete(ctx context.Context, code string) error
25
- FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error)
26
- AllPermissionCodes(ctx context.Context) ([]string, error)
27
- CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error)
28
- }
29
-
30
- // Service = business logic for role (rules live here, it knows nothing about HTTP)
31
- type Service struct {
32
- repo repository
33
- }
34
-
35
- func NewService(repo repository) *Service {
36
- return &Service{repo: repo}
37
- }
38
-
39
- // PermissionsOf resolves roleCode's permission set. This is the func value
40
- // injected into middleware.Authz from main.go — shared/ can't import a
41
- // domain package, so it calls this through a func, not a direct dependency.
42
- func (s *Service) PermissionsOf(ctx context.Context, roleCode string) (map[string]struct{}, error) {
43
- codes, err := s.repo.PermissionCodes(ctx, roleCode)
44
- if err != nil {
45
- return nil, err
46
- }
47
- set := make(map[string]struct{}, len(codes))
48
- for _, c := range codes {
49
- set[c] = struct{}{}
50
- }
51
- return set, nil
52
- }
53
-
54
- // CodeExists reports whether code names a real role — used by user.Service's
55
- // SetRole to validate a role assignment: development-only test schemas never
56
- // create the users_role_fkey FK, so that alone can't be relied on to reject
57
- // an unknown code.
58
- func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
59
- if _, err := s.repo.FindByCode(ctx, code); err != nil {
60
- if errors.Is(err, gorm.ErrRecordNotFound) {
61
- return false, nil
62
- }
63
- return false, err
64
- }
65
- return true, nil
66
- }
67
-
68
- // roleListItem pairs a role with its granted permission codes — every
69
- // role-shaped response (list, create, set-permissions) carries both.
70
- type roleListItem struct {
71
- model.Role
72
- Permissions []string
73
- }
74
-
75
- func (s *Service) List(ctx context.Context, limit, offset int) ([]roleListItem, error) {
76
- roles, err := s.repo.FindAll(ctx, limit, offset)
77
- if err != nil {
78
- return nil, apperror.NewInternal(err)
79
- }
80
- // ponytail: one query per role, not a join. A system has a handful of
81
- // roles, so this is a handful of round trips on an admin screen nobody
82
- // loads in a loop — the join costs more to read than it saves.
83
- //
84
- // Do not copy this shape into a domain whose list is user-sized. There the
85
- // upgrade is one query with a join (or an IN over the page's ids) and a
86
- // group-by in Go; here it would be ceremony.
87
- out := make([]roleListItem, len(roles))
88
- for i, r := range roles {
89
- perms, err := s.repo.PermissionCodes(ctx, r.Code)
90
- if err != nil {
91
- return nil, apperror.NewInternal(err)
92
- }
93
- out[i] = roleListItem{Role: r, Permissions: perms}
94
- }
95
- return out, nil
96
- }
97
-
98
- // roleCodePattern mirrors the seeded system codes' shape (staff, admin) —
99
- // lowercase, no spaces, safe to use as a URL path segment and a JWT claim.
100
- var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
101
-
102
- func (s *Service) Create(ctx context.Context, in CreateInput) (*roleListItem, error) {
103
- if !roleCodePattern.MatchString(in.Code) {
104
- return nil, errInvalidCode()
105
- }
106
- m := &model.Role{Code: in.Code, Name: in.Name}
107
- if err := s.repo.Create(ctx, m); err != nil {
108
- if dberr.IsDuplicate(err) {
109
- return nil, errConflict()
110
- }
111
- return nil, apperror.NewInternal(err)
112
- }
113
- return &roleListItem{Role: *m, Permissions: []string{}}, nil
114
- }
115
-
116
- // SetPermissions replaces code's entire permission grant set with
117
- // permissionCodes — a full-replace, not add/remove, matches a checkbox-style
118
- // admin UI ("here's the new full set") better than diffing.
119
- func (s *Service) SetPermissions(ctx context.Context, code string, permissionCodes []string) (*roleListItem, error) {
120
- m, err := s.repo.FindByCode(ctx, code)
121
- if err != nil {
122
- return nil, wrapFindErr(err)
123
- }
124
- if err := s.validatePermissionCodes(ctx, permissionCodes); err != nil {
125
- return nil, err
126
- }
127
- if !slices.Contains(permissionCodes, PermRoleManage) {
128
- isLast, err := s.isLastRoleManager(ctx, code)
129
- if err != nil {
130
- return nil, apperror.NewInternal(err)
131
- }
132
- if isLast {
133
- return nil, errLastRoleManager()
134
- }
135
- }
136
- if err := s.repo.SetPermissions(ctx, code, permissionCodes); err != nil {
137
- // the migration's FK is a backstop for this same check — reachable
138
- // only under a race with a concurrent permission deletion.
139
- if dberr.IsForeignKey(err) {
140
- return nil, errUnknownPermission()
141
- }
142
- return nil, apperror.NewInternal(err)
143
- }
144
- return &roleListItem{Role: *m, Permissions: permissionCodes}, nil
145
- }
146
-
147
- // validatePermissionCodes checks every code against the actual permission
148
- // catalog — the DB's FK alone isn't enough (the development bootstrap, used in dev/tests,
149
- // never creates it, only the raw migration SQL does).
150
- func (s *Service) validatePermissionCodes(ctx context.Context, codes []string) error {
151
- all, err := s.repo.AllPermissionCodes(ctx)
152
- if err != nil {
153
- return apperror.NewInternal(err)
154
- }
155
- valid := make(map[string]struct{}, len(all))
156
- for _, c := range all {
157
- valid[c] = struct{}{}
158
- }
159
- for _, c := range codes {
160
- if _, ok := valid[c]; !ok {
161
- return errUnknownPermission()
162
- }
163
- }
164
- return nil
165
- }
166
-
167
- func (s *Service) Delete(ctx context.Context, code string) error {
168
- m, err := s.repo.FindByCode(ctx, code)
169
- if err != nil {
170
- return wrapFindErr(err)
171
- }
172
- if m.IsSystem {
173
- return errSystemRole()
174
- }
175
- isLast, err := s.isLastRoleManager(ctx, code)
176
- if err != nil {
177
- return apperror.NewInternal(err)
178
- }
179
- if isLast {
180
- return errLastRoleManager()
181
- }
182
- if err := s.repo.Delete(ctx, code); err != nil {
183
- if dberr.IsForeignKey(err) {
184
- return errHasReferences()
185
- }
186
- return apperror.NewInternal(err)
187
- }
188
- return nil
189
- }
190
-
191
- // isLastRoleManager reports whether code is the only role left granting
192
- // PermRoleManage — blocks a self-lockout no one could fix back through this
193
- // same API. Best-effort: reachable only under a race with a concurrent edit
194
- // to the same permission, not worth a serializable transaction for an
195
- // admin-only, low-traffic action.
196
- func (s *Service) isLastRoleManager(ctx context.Context, code string) (bool, error) {
197
- others, err := s.repo.CountRolesWithPermission(ctx, PermRoleManage, code)
198
- if err != nil {
199
- return false, err
200
- }
201
- return others == 0, nil
202
- }
203
-
204
- func (s *Service) ListPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
205
- items, err := s.repo.FindAllPermissions(ctx, limit, offset)
206
- if err != nil {
207
- return nil, apperror.NewInternal(err)
208
- }
209
- return items, nil
210
- }
211
-
212
- func wrapFindErr(err error) error {
213
- if errors.Is(err, gorm.ErrRecordNotFound) {
214
- return errNotFound()
215
- }
216
- return apperror.NewInternal(err)
217
- }