@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,119 @@
1
+ package role
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "testing"
8
+
9
+ "{{goModule}}/internal/app/role/model"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ )
12
+
13
+ // fakeRepo = mock of the repository interface, so the service can be tested
14
+ // without a DB — countWithPermission stands in for "how many roles OTHER
15
+ // than the one being edited currently grant PermRoleManage", exactly what
16
+ // CountRolesWithPermission's excludeCode param means.
17
+ type fakeRepo struct {
18
+ role *model.Role
19
+ findErr error
20
+ allPermissionCodes []string
21
+ countWithPermission int64
22
+ setPermissionsErr error
23
+ deleteErr error
24
+ }
25
+
26
+ 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) {
29
+ if f.findErr != nil {
30
+ return nil, f.findErr
31
+ }
32
+ return f.role, nil
33
+ }
34
+ func (f *fakeRepo) Create(context.Context, *model.Role) error { return nil }
35
+ func (f *fakeRepo) SetPermissions(context.Context, string, []string) error {
36
+ return f.setPermissionsErr
37
+ }
38
+ func (f *fakeRepo) Delete(context.Context, string) error { return f.deleteErr }
39
+ func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]model.Permission, error) {
40
+ return nil, nil
41
+ }
42
+ func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) { return f.allPermissionCodes, nil }
43
+ func (f *fakeRepo) CountRolesWithPermission(context.Context, string, string) (int64, error) {
44
+ return f.countWithPermission, nil
45
+ }
46
+
47
+ func status(t *testing.T, err error) int {
48
+ t.Helper()
49
+ var appErr *apperror.AppError
50
+ if !errors.As(err, &appErr) {
51
+ t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
52
+ }
53
+ return appErr.HTTPStatus
54
+ }
55
+
56
+ func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
57
+ repo := &fakeRepo{
58
+ role: &model.Role{Code: "ops", Name: "Ops"},
59
+ allPermissionCodes: []string{PermRoleManage, "user:read"},
60
+ countWithPermission: 0, // no OTHER role grants PermRoleManage
61
+ }
62
+ svc := NewService(repo)
63
+ // dropping PermRoleManage from the only role that has it would lock every admin out
64
+ _, err := svc.SetPermissions(context.Background(), "ops", []string{"user:read"})
65
+ if got := status(t, err); got != http.StatusConflict {
66
+ t.Fatalf("want 409 last-role-manager lockout, got %d", got)
67
+ }
68
+ }
69
+
70
+ func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T) {
71
+ repo := &fakeRepo{
72
+ role: &model.Role{Code: "ops", Name: "Ops"},
73
+ allPermissionCodes: []string{PermRoleManage, "user:read"},
74
+ countWithPermission: 1, // another role still grants PermRoleManage
75
+ }
76
+ svc := NewService(repo)
77
+ if _, err := svc.SetPermissions(context.Background(), "ops", []string{"user:read"}); err != nil {
78
+ t.Fatalf("expected success when another role still manages roles, got %v", err)
79
+ }
80
+ }
81
+
82
+ func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
83
+ repo := &fakeRepo{
84
+ role: &model.Role{Code: "ops", Name: "Ops"},
85
+ allPermissionCodes: []string{PermRoleManage},
86
+ countWithPermission: 0,
87
+ }
88
+ svc := NewService(repo)
89
+ // the guard only fires when PermRoleManage is being dropped — keeping it never trips it
90
+ if _, err := svc.SetPermissions(context.Background(), "ops", []string{PermRoleManage}); err != nil {
91
+ t.Fatalf("keeping %s in the new set should never trigger the lockout guard, got %v", PermRoleManage, err)
92
+ }
93
+ }
94
+
95
+ func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
96
+ repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 0}
97
+ svc := NewService(repo)
98
+ err := svc.Delete(context.Background(), "ops")
99
+ if got := status(t, err); got != http.StatusConflict {
100
+ t.Fatalf("want 409 last-role-manager lockout on delete, got %d", got)
101
+ }
102
+ }
103
+
104
+ func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
105
+ repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 1}
106
+ svc := NewService(repo)
107
+ if err := svc.Delete(context.Background(), "ops"); err != nil {
108
+ t.Fatalf("expected delete to succeed when another role still manages roles, got %v", err)
109
+ }
110
+ }
111
+
112
+ func TestService_Delete_SystemRoleRejected(t *testing.T) {
113
+ repo := &fakeRepo{role: &model.Role{Code: "admin", IsSystem: true}}
114
+ svc := NewService(repo)
115
+ err := svc.Delete(context.Background(), "admin")
116
+ if got := status(t, err); got != http.StatusConflict {
117
+ t.Fatalf("want 409 for a system role, got %d", got)
118
+ }
119
+ }
@@ -0,0 +1,88 @@
1
+ package middleware
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "sync"
7
+ "time"
8
+
9
+ "{{goModule}}/internal/shared/apperror"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ // Authz answers "does this role have this permission?". shared/ can't import
15
+ // a domain package (see RequireAuth's comment), so the role->permission
16
+ // lookup is injected as a func value from main.go instead of calling the
17
+ // role domain directly.
18
+ type Authz struct {
19
+ resolve func(ctx context.Context, roleCode string) (map[string]struct{}, error)
20
+ ttl time.Duration
21
+
22
+ mu sync.RWMutex
23
+ cache map[string]cachedPerms
24
+ }
25
+
26
+ type cachedPerms struct {
27
+ perms map[string]struct{}
28
+ expires time.Time
29
+ }
30
+
31
+ // NewAuthz builds an Authz that calls resolve on a cache miss. The cache key
32
+ // is the role code, not the user or the request — a handful of roles means
33
+ // a handful of DB round trips per TTL window, independent of traffic.
34
+ func NewAuthz(resolve func(context.Context, string) (map[string]struct{}, error), ttl time.Duration) *Authz {
35
+ return &Authz{resolve: resolve, ttl: ttl, cache: map[string]cachedPerms{}}
36
+ }
37
+
38
+ // Require 403s unless the caller's role (set by RequireAuth) grants perm.
39
+ // Must run after RequireAuth.
40
+ func (a *Authz) Require(perm string) gin.HandlerFunc {
41
+ return func(c *gin.Context) {
42
+ perms, err := a.permsOf(c.Request.Context(), c.GetString(RoleKey))
43
+ if err != nil {
44
+ c.Error(err)
45
+ c.Abort()
46
+ return
47
+ }
48
+ if _, ok := perms[perm]; !ok {
49
+ c.Error(apperror.New(http.StatusForbidden, "FORBIDDEN", "insufficient permissions"))
50
+ c.Abort()
51
+ return
52
+ }
53
+ c.Next()
54
+ }
55
+ }
56
+
57
+ // ponytail: process-local cache, a handful of roles x AUTHZ_CACHE_TTL_MIN —
58
+ // a role's permission grants take effect within the TTL, no pub/sub needed.
59
+ // Move to Redis only if instant cross-pod invalidation turns out to matter.
60
+ //
61
+ // Double-checked locking: the common case (cache hit) only needs RLock, so
62
+ // concurrent requests for different roles never block each other. Only a
63
+ // miss takes the exclusive Lock, and only for the one role being resolved —
64
+ // ceiling is that a miss still blocks other roles' misses (not their hits)
65
+ // for the duration of one resolve() call; move to a per-role singleflight
66
+ // if that contention ever actually shows up under load.
67
+ func (a *Authz) permsOf(ctx context.Context, role string) (map[string]struct{}, error) {
68
+ a.mu.RLock()
69
+ e, ok := a.cache[role]
70
+ a.mu.RUnlock()
71
+ if ok && time.Now().Before(e.expires) {
72
+ return e.perms, nil
73
+ }
74
+
75
+ a.mu.Lock()
76
+ defer a.mu.Unlock()
77
+ // re-check: another goroutine may have refreshed this role while we
78
+ // were waiting for the write lock.
79
+ if e, ok := a.cache[role]; ok && time.Now().Before(e.expires) {
80
+ return e.perms, nil
81
+ }
82
+ perms, err := a.resolve(ctx, role)
83
+ if err != nil {
84
+ return nil, err
85
+ }
86
+ a.cache[role] = cachedPerms{perms: perms, expires: time.Now().Add(a.ttl)}
87
+ return perms, nil
88
+ }
@@ -0,0 +1,88 @@
1
+ package middleware
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "sync/atomic"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ func TestAuthz_CachesWithinTTL(t *testing.T) {
15
+ var calls int32
16
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
17
+ atomic.AddInt32(&calls, 1)
18
+ return map[string]struct{}{"x": {}}, nil
19
+ }, time.Minute)
20
+
21
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
22
+ t.Fatalf("unexpected error: %v", err)
23
+ }
24
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
25
+ t.Fatalf("unexpected error: %v", err)
26
+ }
27
+ if got := atomic.LoadInt32(&calls); got != 1 {
28
+ t.Fatalf("want resolve called once for two calls within TTL (cache hit), got %d", got)
29
+ }
30
+ }
31
+
32
+ func TestAuthz_ReResolvesAfterTTLExpires(t *testing.T) {
33
+ var calls int32
34
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
35
+ atomic.AddInt32(&calls, 1)
36
+ return map[string]struct{}{"x": {}}, nil
37
+ }, 10*time.Millisecond)
38
+
39
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
40
+ t.Fatalf("unexpected error: %v", err)
41
+ }
42
+ time.Sleep(20 * time.Millisecond)
43
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
44
+ t.Fatalf("unexpected error: %v", err)
45
+ }
46
+ if got := atomic.LoadInt32(&calls); got != 2 {
47
+ t.Fatalf("want resolve called again once the TTL expires, got %d calls", got)
48
+ }
49
+ }
50
+
51
+ func TestAuthz_Require_ForbidsMissingPermission(t *testing.T) {
52
+ gin.SetMode(gin.TestMode)
53
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
54
+ return map[string]struct{}{"other:perm": {}}, nil
55
+ }, time.Minute)
56
+
57
+ r := gin.New()
58
+ // Require only records a *apperror.AppError via c.Error — Error(true) is
59
+ // what actually translates that into the response status code, same as
60
+ // the real router (see cmd/api/main.go).
61
+ r.Use(Error(true))
62
+ r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "staff") }, a.Require("role:manage"), func(c *gin.Context) {
63
+ c.Status(http.StatusOK)
64
+ })
65
+ w := httptest.NewRecorder()
66
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
67
+ if w.Code != http.StatusForbidden {
68
+ t.Fatalf("want 403 without the required permission, got %d", w.Code)
69
+ }
70
+ }
71
+
72
+ func TestAuthz_Require_AllowsGrantedPermission(t *testing.T) {
73
+ gin.SetMode(gin.TestMode)
74
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
75
+ return map[string]struct{}{"role:manage": {}}, nil
76
+ }, time.Minute)
77
+
78
+ r := gin.New()
79
+ r.Use(Error(true))
80
+ r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "admin") }, a.Require("role:manage"), func(c *gin.Context) {
81
+ c.Status(http.StatusOK)
82
+ })
83
+ w := httptest.NewRecorder()
84
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
85
+ if w.Code != http.StatusOK {
86
+ t.Fatalf("want 200 with the required permission granted, got %d", w.Code)
87
+ }
88
+ }
@@ -0,0 +1,15 @@
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 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 users DROP COLUMN role;
12
+
13
+ DROP TABLE role_permissions;
14
+ DROP TABLE permissions;
15
+ DROP TABLE roles;
@@ -0,0 +1,35 @@
1
+ CREATE TABLE roles (
2
+ code VARCHAR(20) PRIMARY KEY,
3
+ name TEXT NOT NULL,
4
+ is_system BOOLEAN NOT NULL DEFAULT FALSE,
5
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
6
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
7
+ );
8
+
9
+ CREATE TABLE permissions (
10
+ code VARCHAR(50) PRIMARY KEY,
11
+ description TEXT NOT NULL
12
+ );
13
+
14
+ CREATE TABLE role_permissions (
15
+ role_code VARCHAR(20) NOT NULL REFERENCES roles(code) ON DELETE CASCADE,
16
+ permission_code VARCHAR(50) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE,
17
+ PRIMARY KEY (role_code, permission_code)
18
+ );
19
+
20
+ INSERT INTO roles (code, name, is_system) VALUES
21
+ ('staff', 'Staff', TRUE),
22
+ ('admin', 'Admin', TRUE);
23
+
24
+ INSERT INTO permissions (code, description) VALUES
25
+ ('role:manage', 'Create roles and change their permissions'),
26
+ ('user:manage-role', 'Change a user''s assigned role'),
27
+ ('user:read', 'List and view other users');
28
+
29
+ -- admin gets every permission that exists, staff gets none — no behavior
30
+ -- change for existing users until an admin explicitly grants something.
31
+ INSERT INTO role_permissions (role_code, permission_code)
32
+ SELECT 'admin', code FROM permissions;
33
+
34
+ ALTER TABLE users
35
+ ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES roles(code);
@@ -0,0 +1,77 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "log/slog"
7
+ "os"
8
+ "os/signal"
9
+ "syscall"
10
+ "time"
11
+
12
+ "{{goModule}}/internal/platform/mail"
13
+ "{{goModule}}/internal/platform/queue"
14
+ "{{goModule}}/internal/shared/config"
15
+ // go-scaffold:imports
16
+ )
17
+
18
+ func main() {
19
+ cfg := config.Load()
20
+
21
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
22
+ slog.SetDefault(logger)
23
+
24
+ srv, err := queue.NewServer(cfg.RedisURL)
25
+ if err != nil {
26
+ logger.Error("new queue server", "error", err)
27
+ os.Exit(1)
28
+ }
29
+
30
+ srv.Handle(mail.TypeSendEmail, mail.HandleSendEmail(mail.Open(cfg)))
31
+ // go-scaffold:queue-handlers
32
+
33
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
34
+ defer stop()
35
+
36
+ go func() {
37
+ logger.Info("worker started")
38
+ if err := srv.Start(); err != nil && !errors.Is(err, context.Canceled) {
39
+ logger.Error("worker server", "error", err)
40
+ os.Exit(1)
41
+ }
42
+ }()
43
+
44
+ <-ctx.Done()
45
+ logger.Info("shutting down worker")
46
+
47
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
48
+ defer cancel()
49
+ // asynq's Shutdown() blocks until in-flight tasks finish (or the process
50
+ // is killed) with no context/timeout parameter of its own — run it in a
51
+ // goroutine and race it against shutdownCtx so a stuck task can't hang
52
+ // the process forever.
53
+ done := make(chan struct{}, 1)
54
+ go func() {
55
+ srv.Shutdown()
56
+ close(done)
57
+ }()
58
+ select {
59
+ case <-done:
60
+ case <-shutdownCtx.Done():
61
+ logger.Warn("worker shutdown timed out")
62
+ }
63
+ logger.Info("worker stopped")
64
+ }
65
+
66
+ func parseLevel(s string) slog.Level {
67
+ switch s {
68
+ case "debug":
69
+ return slog.LevelDebug
70
+ case "warn":
71
+ return slog.LevelWarn
72
+ case "error":
73
+ return slog.LevelError
74
+ default:
75
+ return slog.LevelInfo
76
+ }
77
+ }
@@ -0,0 +1,18 @@
1
+ package cache
2
+
3
+ import (
4
+ "{{goModule}}/internal/shared/config"
5
+
6
+ "github.com/redis/go-redis/v9"
7
+ )
8
+
9
+ // Open connects to Redis — it talks to a real external system, so it lives
10
+ // in platform/, not shared/. Used for ephemeral, TTL-bound data and as the
11
+ // backing store for the async task queue (platform/queue).
12
+ func Open(cfg config.Config) (*redis.Client, error) {
13
+ opts, err := redis.ParseURL(cfg.RedisURL)
14
+ if err != nil {
15
+ return nil, err
16
+ }
17
+ return redis.NewClient(opts), nil
18
+ }
@@ -0,0 +1,48 @@
1
+ package mail
2
+
3
+ import (
4
+ "fmt"
5
+ "log/slog"
6
+ "net/smtp"
7
+
8
+ "{{goModule}}/internal/shared/config"
9
+ )
10
+
11
+ // Client sends plain-text email over SMTP (net/smtp already handles STARTTLS
12
+ // when the server offers it, which covers Gmail/SendGrid/Mailgun/SES/Resend
13
+ // SMTP relays on port 587) — talks to a real external system, so it lives in
14
+ // platform/, not shared/.
15
+ type Client struct {
16
+ host, port, username, password, from string
17
+ }
18
+
19
+ func Open(cfg config.Config) *Client {
20
+ return &Client{
21
+ host: cfg.SMTPHost,
22
+ port: cfg.SMTPPort,
23
+ username: cfg.SMTPUsername,
24
+ password: cfg.SMTPPassword,
25
+ from: cfg.SMTPFrom,
26
+ }
27
+ }
28
+
29
+ // Send delivers a plain-text email. If SMTP isn't configured (dev default —
30
+ // SMTP_HOST unset), it logs the message instead of failing, so flows that
31
+ // send email stay testable without a real mail server.
32
+ func (c *Client) Send(to, subject, body string) error {
33
+ if c.host == "" {
34
+ // ponytail: no SMTP configured — log instead of failing. Upgrade path:
35
+ // set SMTP_HOST (+ USERNAME/PASSWORD/FROM) once a real environment needs it.
36
+ slog.Info("email not sent (SMTP not configured)", "to", to, "subject", subject, "body", body)
37
+ return nil
38
+ }
39
+ msg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, c.from, subject, body)
40
+ // nil Auth when no username is set — a relay/local catcher that doesn't
41
+ // require auth (or doesn't advertise AUTH at all) rejects a non-nil Auth
42
+ // outright, even with empty credentials.
43
+ var auth smtp.Auth
44
+ if c.username != "" {
45
+ auth = smtp.PlainAuth("", c.username, c.password, c.host)
46
+ }
47
+ return smtp.SendMail(c.host+":"+c.port, auth, c.from, []string{to}, []byte(msg))
48
+ }
@@ -0,0 +1,52 @@
1
+ package mail
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+
7
+ "github.com/hibiken/asynq"
8
+ )
9
+
10
+ const TypeSendEmail = "email:send"
11
+
12
+ type sendEmailPayload struct {
13
+ To string
14
+ Subject string
15
+ Body string
16
+ }
17
+
18
+ func NewSendEmailTask(p sendEmailPayload) *asynq.Task {
19
+ payload, _ := json.Marshal(p)
20
+ return asynq.NewTask(TypeSendEmail, payload)
21
+ }
22
+
23
+ func HandleSendEmail(client *Client) asynq.Handler {
24
+ return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
25
+ var p sendEmailPayload
26
+ if err := json.Unmarshal(task.Payload(), &p); err != nil {
27
+ return err
28
+ }
29
+ return client.Send(p.To, p.Subject, p.Body)
30
+ })
31
+ }
32
+
33
+ type AsyncClient struct {
34
+ q interface {
35
+ Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
36
+ }
37
+ }
38
+
39
+ func NewAsyncClient(q interface {
40
+ Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
41
+ }) *AsyncClient {
42
+ return &AsyncClient{q: q}
43
+ }
44
+
45
+ // Send enqueues the email instead of sending it inline, so the caller (an
46
+ // HTTP handler) returns in ~µs instead of blocking on SMTP. cmd/worker picks
47
+ // it up and calls Client.Send.
48
+ func (c *AsyncClient) Send(to, subject, body string) error {
49
+ task := NewSendEmailTask(sendEmailPayload{To: to, Subject: subject, Body: body})
50
+ _, err := c.q.Enqueue(task, asynq.MaxRetry(5))
51
+ return err
52
+ }
@@ -0,0 +1,31 @@
1
+ package queue
2
+
3
+ import (
4
+ "github.com/hibiken/asynq"
5
+ )
6
+
7
+ // Client enqueues tasks to Redis. Created by cmd/api; each domain or platform
8
+ // package defines its own task types and calls Client.Enqueue.
9
+ type Client struct {
10
+ inner *asynq.Client
11
+ }
12
+
13
+ func NewClient(redisURL string) (*Client, error) {
14
+ opt, err := asynq.ParseRedisURI(redisURL)
15
+ if err != nil {
16
+ return nil, err
17
+ }
18
+ return &Client{inner: asynq.NewClient(opt)}, nil
19
+ }
20
+
21
+ func (c *Client) Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error) {
22
+ info, err := c.inner.Enqueue(task, opts...)
23
+ if err != nil {
24
+ return "", err
25
+ }
26
+ return info.ID, nil
27
+ }
28
+
29
+ func (c *Client) Close() error {
30
+ return c.inner.Close()
31
+ }
@@ -0,0 +1,68 @@
1
+ package queue
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+
8
+ "github.com/hibiken/asynq"
9
+ )
10
+
11
+ // Server runs the background worker. Created by cmd/worker; handlers register
12
+ // their task types on the mux before Start is called.
13
+ type Server struct {
14
+ inner *asynq.Server
15
+ mux *asynq.ServeMux
16
+ }
17
+
18
+ // slogAdapter wraps *slog.Logger to implement asynq.Logger. Asynq calls these
19
+ // like the stdlib `log` package (a plain message, sometimes several args
20
+ // meant to be concatenated) — fmt.Sprint joins them into one message string
21
+ // rather than passing them as slog key-value pairs, which would otherwise
22
+ // misinterpret a lone message argument as a key with no value.
23
+ type slogAdapter struct {
24
+ inner *slog.Logger
25
+ }
26
+
27
+ func (a *slogAdapter) Debug(args ...interface{}) { a.inner.Debug(fmt.Sprint(args...)) }
28
+ func (a *slogAdapter) Info(args ...interface{}) { a.inner.Info(fmt.Sprint(args...)) }
29
+ func (a *slogAdapter) Warn(args ...interface{}) { a.inner.Warn(fmt.Sprint(args...)) }
30
+ func (a *slogAdapter) Error(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
31
+ func (a *slogAdapter) Fatal(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
32
+
33
+ func NewServer(redisURL string) (*Server, error) {
34
+ opt, err := asynq.ParseRedisURI(redisURL)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+ srv := &Server{
39
+ inner: asynq.NewServer(opt, asynq.Config{
40
+ Concurrency: 10,
41
+ Logger: &slogAdapter{inner: slog.Default()},
42
+ }),
43
+ mux: asynq.NewServeMux(),
44
+ }
45
+ // Global error handler for tasks that panic or return a non-Retryable error.
46
+ srv.mux.Use(func(next asynq.Handler) asynq.Handler {
47
+ return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
48
+ err := next.ProcessTask(ctx, task)
49
+ if err != nil {
50
+ slog.Error("task failed", "type", task.Type(), "error", err)
51
+ }
52
+ return err
53
+ })
54
+ })
55
+ return srv, nil
56
+ }
57
+
58
+ func (s *Server) Handle(pattern string, handler asynq.Handler) {
59
+ s.mux.Handle(pattern, handler)
60
+ }
61
+
62
+ func (s *Server) Start() error {
63
+ return s.inner.Start(s.mux)
64
+ }
65
+
66
+ func (s *Server) Shutdown() {
67
+ s.inner.Shutdown()
68
+ }
@@ -1,3 +1,8 @@
1
+ # development | production — the one gate for prod behavior (config.IsProd):
2
+ # hides error `details` from responses. Any other value makes the app refuse
3
+ # to boot (so a typo can't silently downgrade prod).
4
+ APP_ENV=development # prod: production
5
+
1
6
  PORT=8080
2
7
  DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable
3
8
  LOG_LEVEL=info
@@ -5,3 +10,18 @@ AUTO_MIGRATE=true
5
10
  DB_MAX_OPEN_CONNS=10
6
11
  DB_MAX_IDLE_CONNS=10
7
12
  DB_CONN_MAX_LIFETIME_MIN=5
13
+
14
+ # read only by `go test`, never by cmd/api — a separate database on purpose: the
15
+ # integration-test harness does DropTable+AutoMigrate on every run, which would
16
+ # otherwise wipe the schema `make migrate-up` built in DB_DSN above.
17
+ # Create it once: make db-create DB_NAME={{dbName}}_test
18
+ TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
19
+
20
+ # comma-separated frontend origins allowed to call this API with credentials (cookies)
21
+ CORS_ALLOWED_ORIGINS=http://localhost:3000
22
+ {{#if observability}}
23
+
24
+ # OTLP/HTTP endpoint for trace export (e.g. localhost:4318) — empty disables
25
+ # tracing entirely: no exporter is created, no network calls are made
26
+ OTEL_EXPORTER_OTLP_ENDPOINT=
27
+ {{/if}}