@nakedev/go-scaffold 0.4.0 → 0.4.3

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 (118) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +42 -1
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/platform-patcher.js +29 -7
  28. package/dist/utils/rbac-patcher.js +97 -75
  29. package/package.json +7 -2
  30. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  31. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  32. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  33. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  34. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  35. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  38. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  39. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  41. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  42. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  43. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  44. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  45. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  46. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  47. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  48. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  49. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  50. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  51. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  52. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  53. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  54. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  55. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  56. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  57. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  58. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  59. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  60. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  61. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  62. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  63. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  64. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  65. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  66. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  67. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  69. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  70. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  71. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  72. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  73. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  74. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  75. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  76. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  77. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  78. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  79. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  80. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  82. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  83. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  84. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  86. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  87. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  88. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  89. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  90. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  91. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  92. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  93. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  94. package/templates/create/base/.env.example.hbs +0 -1
  95. package/templates/create/base/AGENTS.md.hbs +255 -67
  96. package/templates/create/base/Makefile.hbs +2 -1
  97. package/templates/create/base/README.md.hbs +42 -14
  98. package/templates/create/base/cmd/api/wiring.go.hbs +11 -8
  99. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  100. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  101. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  102. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  103. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  104. package/templates/create/features/docs/architecture.md.hbs +35 -13
  105. package/templates/create/features/docs/patterns.md.hbs +40 -21
  106. package/templates/create/features/docs/techstack.md.hbs +2 -2
  107. package/templates/generate/module/commands.go.hbs +95 -0
  108. package/templates/generate/module/composition.go.hbs +23 -0
  109. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  110. package/templates/generate/module/handler.go.hbs +50 -5
  111. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  112. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  113. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  114. package/templates/generate/module/minimal/service.go.hbs +27 -1
  115. package/templates/generate/module/queries.go.hbs +62 -0
  116. package/templates/generate/module/service.go.hbs +61 -5
  117. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  118. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -6,12 +6,20 @@ exports.promptMethodType = promptMethodType;
6
6
  exports.promptGetMode = promptGetMode;
7
7
  exports.promptMigrationName = promptMigrationName;
8
8
  exports.promptLookupField = promptLookupField;
9
+ exports.promptModuleSurface = promptModuleSurface;
10
+ exports.promptApplicationStyle = promptApplicationStyle;
11
+ exports.promptModuleProfile = promptModuleProfile;
12
+ exports.promptAdvancedModuleArchitecture = promptAdvancedModuleArchitecture;
13
+ exports.moduleArchitectureForProfile = moduleArchitectureForProfile;
14
+ exports.moduleProfileDescription = moduleProfileDescription;
9
15
  exports.promptModuleShape = promptModuleShape;
16
+ exports.promptModuleCqrs = promptModuleCqrs;
10
17
  exports.promptModuleAuth = promptModuleAuth;
11
18
  exports.promptModulePermission = promptModulePermission;
12
19
  exports.promptExistingModule = promptExistingModule;
13
20
  const interactive_1 = require("./interactive");
14
21
  const naming_1 = require("../utils/naming");
22
+ const module_profile_1 = require("../utils/module-profile");
15
23
  // wraps an assert-style validator into inquirer's true|string contract so a
16
24
  // reserved word re-prompts inline instead of aborting the whole command.
17
25
  function notKeyword(value, role) {
@@ -78,29 +86,101 @@ async function promptLookupField() {
78
86
  });
79
87
  return field.trim();
80
88
  }
81
- // The three `generate module` decisions that exist as flags (--full, --auth,
82
- // --permission). They live here next to the other generate prompts so the
83
- // subcommand and the bare-menu path can ask them the same way a choice only
84
- // reachable by knowing the flag name isn't a choice for anyone driving this
85
- // from the menu.
86
- async function promptModuleShape() {
89
+ // The `generate module` choices live here next to the other generate prompts so
90
+ // the subcommand, bare menu, and project config wizard ask them consistently
91
+ // a choice only reachable by knowing a flag name is not a choice for someone
92
+ // driving the CLI interactively.
93
+ async function promptModuleSurface(defaultValue = "minimal") {
87
94
  return (0, interactive_1.select)({
88
95
  message: "What should the module contain?",
89
- default: false,
96
+ default: defaultValue,
90
97
  choices: [
91
98
  {
92
99
  name: "Minimal — model + wiring only",
93
- value: false,
100
+ value: "minimal",
94
101
  description: "the safe default; add endpoints one at a time with `generate method`",
95
102
  },
96
103
  {
97
104
  name: "CRUD skeleton — list/get/create/update/delete",
98
- value: true,
105
+ value: "crud",
99
106
  description: "all five endpoints wired up; DTO fields and business rules are left as TODO",
100
107
  },
101
108
  ],
102
109
  });
103
110
  }
111
+ async function promptApplicationStyle(defaultValue = "service") {
112
+ return (0, interactive_1.select)({
113
+ message: "How should the application boundary be organised?",
114
+ default: defaultValue,
115
+ choices: [
116
+ {
117
+ name: "Single service",
118
+ value: "service",
119
+ description: "the simpler path; use this unless reads and writes have different business needs",
120
+ },
121
+ {
122
+ name: "CQRS command/query handlers",
123
+ value: "cqrs",
124
+ description: "separate state-changing commands from read-only queries; storage remains shared by default",
125
+ },
126
+ ],
127
+ });
128
+ }
129
+ /**
130
+ * Ask for the useful decision first. The underlying surface/application
131
+ * choices remain available through Advanced and through the CLI flags, but a
132
+ * new user should not have to understand two architecture axes before making
133
+ * a sensible choice.
134
+ */
135
+ async function promptModuleProfile(defaultValue = "lean") {
136
+ return (0, interactive_1.select)({
137
+ message: "Choose a module profile:",
138
+ default: defaultValue,
139
+ choices: [
140
+ {
141
+ name: "Lean — minimal + single service",
142
+ value: "lean",
143
+ description: "model, repository, service and handler wiring; add only the endpoints this domain needs",
144
+ },
145
+ {
146
+ name: "CRUD — CRUD surface + single service",
147
+ value: "crud",
148
+ description: "list/get/create/update/delete skeletons; fields and business rules remain TODOs",
149
+ },
150
+ {
151
+ name: "CQRS — minimal surface + command/query handlers",
152
+ value: "cqrs",
153
+ description: "separate state-changing commands from read-only queries; no broker or second database is added",
154
+ },
155
+ {
156
+ name: "Advanced — choose surface and application boundary",
157
+ value: "advanced",
158
+ description: "for the uncommon CRUD + CQRS combination or a deliberately customised module",
159
+ },
160
+ ],
161
+ });
162
+ }
163
+ async function promptAdvancedModuleArchitecture(defaultSurface = "minimal", defaultApplicationStyle = "service") {
164
+ return {
165
+ moduleSurface: await promptModuleSurface(defaultSurface),
166
+ applicationStyle: await promptApplicationStyle(defaultApplicationStyle),
167
+ };
168
+ }
169
+ function moduleArchitectureForProfile(profile) {
170
+ return (0, module_profile_1.architectureForModuleProfile)(profile);
171
+ }
172
+ function moduleProfileDescription(moduleSurface, applicationStyle) {
173
+ return (0, module_profile_1.describeModuleProfile)((0, module_profile_1.moduleProfileFor)(moduleSurface, applicationStyle));
174
+ }
175
+ // Backward-compatible boolean helpers for the existing generate-module flow.
176
+ // The richer enum prompts are also used by the project config wizard so one
177
+ // set of choices describes both the default and the per-module override.
178
+ async function promptModuleShape(defaultValue = false) {
179
+ return (await promptModuleSurface(defaultValue ? "crud" : "minimal")) === "crud";
180
+ }
181
+ async function promptModuleCqrs(defaultValue = false) {
182
+ return (await promptApplicationStyle(defaultValue ? "cqrs" : "service")) === "cqrs";
183
+ }
104
184
  async function promptModuleAuth() {
105
185
  return (0, interactive_1.confirm)({
106
186
  message: "Require a valid access token for this module's routes?",
@@ -8,27 +8,57 @@ const SHARED = [
8
8
  { template: "add/auth/internal/app/user/model/user.go.hbs", output: "internal/app/user/model/user.go" },
9
9
  { template: "add/auth/internal/app/user/model/identity.go.hbs", output: "internal/app/user/model/identity.go" },
10
10
  { template: "add/auth/internal/app/user/model/loginthrottle.go.hbs", output: "internal/app/user/model/loginthrottle.go" },
11
+ { template: "add/auth/internal/app/user/model/authtoken.go.hbs", output: "internal/app/user/model/authtoken.go" },
12
+ { template: "add/auth/internal/app/user/model/mfa_enrollment.go.hbs", output: "internal/app/user/model/mfa_enrollment.go" },
13
+ { template: "add/auth/internal/app/user/model/mfa_challenge.go.hbs", output: "internal/app/user/model/mfa_challenge.go" },
14
+ { template: "add/auth/internal/app/user/model/mfa_recovery_code.go.hbs", output: "internal/app/user/model/mfa_recovery_code.go" },
15
+ { template: "add/auth/internal/app/user/application/recovery.go.hbs", output: "internal/app/user/application/recovery.go" },
16
+ { template: "add/auth/internal/app/user/application/oauth.go.hbs", output: "internal/app/user/application/oauth.go" },
17
+ { template: "add/auth/internal/app/user/contracts.go.hbs", output: "internal/app/user/contracts.go" },
18
+ { template: "add/auth/internal/app/user/composition.go.hbs", output: "internal/app/user/composition.go" },
19
+ { template: "add/auth/internal/app/user/provider_test.go.hbs", output: "internal/app/user/provider_test.go" },
20
+ { template: "add/auth/internal/app/user/handler_test.go.hbs", output: "internal/app/user/handler_test.go" },
21
+ { template: "add/auth/internal/app/user/tokenstore_recovery.go.hbs", output: "internal/app/user/tokenstore_recovery.go" },
11
22
  { template: "add/auth/internal/app/user/dto.go.hbs", output: "internal/app/user/dto.go" },
12
23
  { template: "add/auth/internal/app/user/errors.go.hbs", output: "internal/app/user/errors.go" },
13
24
  { template: "add/auth/internal/app/user/jwt.go.hbs", output: "internal/app/user/jwt.go" },
14
25
  { template: "add/auth/internal/app/user/tokenstore.go.hbs", output: "internal/app/user/tokenstore.go" },
26
+ { template: "add/auth/internal/app/user/mfa_store.go.hbs", output: "internal/app/user/mfa_store.go" },
27
+ { template: "add/auth/internal/app/user/mfa_store_test.go.hbs", output: "internal/app/user/mfa_store_test.go" },
28
+ { template: "add/auth/internal/app/user/mfa_service.go.hbs", output: "internal/app/user/mfa_service.go" },
29
+ { template: "add/auth/internal/app/user/mfa_service_test.go.hbs", output: "internal/app/user/mfa_service_test.go" },
15
30
  { template: "add/auth/internal/app/user/repository.go.hbs", output: "internal/app/user/repository.go" },
16
31
  { template: "add/auth/internal/app/user/service.go.hbs", output: "internal/app/user/service.go" },
32
+ { template: "add/auth/internal/app/user/local_auth.go.hbs", output: "internal/app/user/local_auth.go" },
33
+ { template: "add/auth/internal/app/user/sessions.go.hbs", output: "internal/app/user/sessions.go" },
34
+ { template: "add/auth/internal/app/user/recovery_service.go.hbs", output: "internal/app/user/recovery_service.go" },
35
+ { template: "add/auth/internal/app/user/external_login.go.hbs", output: "internal/app/user/external_login.go" },
36
+ { template: "add/auth/internal/app/user/user_query.go.hbs", output: "internal/app/user/user_query.go" },
17
37
  { template: "add/auth/internal/app/user/service_test.go.hbs", output: "internal/app/user/service_test.go" },
18
38
  { template: "add/auth/internal/app/user/repository_test.go.hbs", output: "internal/app/user/repository_test.go" },
19
39
  { template: "add/auth/internal/app/user/handler.go.hbs", output: "internal/app/user/handler.go" },
40
+ { template: "add/auth/internal/app/user/handler_local.go.hbs", output: "internal/app/user/handler_local.go" },
41
+ { template: "add/auth/internal/app/user/handler_recovery.go.hbs", output: "internal/app/user/handler_recovery.go" },
42
+ { template: "add/auth/internal/app/user/handler_oauth.go.hbs", output: "internal/app/user/handler_oauth.go" },
43
+ { template: "add/auth/internal/app/user/handler_user.go.hbs", output: "internal/app/user/handler_user.go" },
44
+ { template: "add/auth/internal/app/user/handler_mfa.go.hbs", output: "internal/app/user/handler_mfa.go" },
45
+ { template: "add/auth/internal/app/user/session_cookie.go.hbs", output: "internal/app/user/session_cookie.go" },
46
+ { template: "add/auth/internal/app/user/browser_policy.go.hbs", output: "internal/app/user/browser_policy.go" },
47
+ { template: "add/auth/internal/platform/authprovider/google/google.go.hbs", output: "internal/platform/authprovider/google/google.go" },
48
+ { template: "add/auth/internal/platform/authprovider/google/google_test.go.hbs", output: "internal/platform/authprovider/google/google_test.go" },
20
49
  { template: "add/auth/cmd/seed/main.go.hbs", output: "cmd/seed/main.go" },
21
50
  ];
22
51
  // Only the chosen store's implementation is written. Shipping both would drag
23
52
  // go-redis into every project's go.mod for a file it never constructs — the
24
53
  // same reason `add worker` writes one queue adapter, not two.
25
54
  const POSTGRES = [
26
- { template: "add/auth/internal/app/user/model/authtoken.go.hbs", output: "internal/app/user/model/authtoken.go" },
27
55
  { template: "add/auth/internal/app/user/tokenstore_pg.go.hbs", output: "internal/app/user/tokenstore_pg.go" },
56
+ { template: "add/auth/internal/app/user/tokenstore_pg_test.go.hbs", output: "internal/app/user/tokenstore_pg_test.go" },
28
57
  { template: "add/auth/internal/shared/middleware/ratelimit_memory.go.hbs", output: "internal/shared/middleware/ratelimit_memory.go" },
29
58
  ];
30
59
  const REDIS = [
31
60
  { template: "add/auth/internal/app/user/tokenstore_redis.go.hbs", output: "internal/app/user/tokenstore_redis.go" },
61
+ { template: "add/auth/internal/app/user/tokenstore_redis_test.go.hbs", output: "internal/app/user/tokenstore_redis_test.go" },
32
62
  { template: "add/auth/internal/shared/middleware/ratelimit_redis.go.hbs", output: "internal/shared/middleware/ratelimit_redis.go" },
33
63
  ];
34
64
  function authFiles(store) {
@@ -57,6 +57,10 @@ exports.CREATE_MANIFEST = [
57
57
  template: "create/base/internal/shared/middleware/cors.go.hbs",
58
58
  output: "internal/shared/middleware/cors.go",
59
59
  },
60
+ {
61
+ template: "create/base/internal/shared/middleware/cors_test.go.hbs",
62
+ output: "internal/shared/middleware/cors_test.go",
63
+ },
60
64
  {
61
65
  template: "create/base/internal/shared/middleware/error.go.hbs",
62
66
  output: "internal/shared/middleware/error.go",
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MODULE_FILES_MINIMAL = exports.MODULE_FILES = void 0;
3
+ exports.MODULE_FILES_MINIMAL_CQRS = exports.MODULE_FILES_MINIMAL = exports.MODULE_FILES_CQRS = exports.MODULE_FILES = void 0;
4
4
  // output paths are relative to the module's own directory
5
5
  // (internal/app/<pkg> or internal/app/v<version>/<pkg>)
6
6
  exports.MODULE_FILES = [
@@ -9,11 +9,31 @@ exports.MODULE_FILES = [
9
9
  { template: "generate/module/errors.go.hbs", output: "errors.go" },
10
10
  { template: "generate/module/repository.go.hbs", output: "repository.go" },
11
11
  { template: "generate/module/service.go.hbs", output: "service.go" },
12
+ { template: "generate/module/composition.go.hbs", output: "composition.go" },
12
13
  { template: "generate/module/handler.go.hbs", output: "handler.go" },
13
14
  { template: "generate/module/service_test.go.hbs", output: "service_test.go" },
14
15
  { template: "generate/module/handler_test.go.hbs", output: "handler_test.go" },
15
16
  { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
16
17
  ];
18
+ // CQRS is opt-in so a simple CRUD module does not pay for command/query
19
+ // ceremony. The implementation files are still in the feature package, which
20
+ // keeps the module easy to adopt incrementally and lets `generate method` patch
21
+ // the correct side later.
22
+ exports.MODULE_FILES_CQRS = [
23
+ { template: "generate/module/model/model.go.hbs", output: "model/model.go" },
24
+ { template: "generate/module/dto.go.hbs", output: "dto.go" },
25
+ { template: "generate/module/errors.go.hbs", output: "errors.go" },
26
+ { template: "generate/module/repository.go.hbs", output: "repository.go" },
27
+ { template: "generate/module/commands.go.hbs", output: "commands.go" },
28
+ { template: "generate/module/queries.go.hbs", output: "queries.go" },
29
+ { template: "generate/module/service.go.hbs", output: "service.go" },
30
+ { template: "generate/module/composition.go.hbs", output: "composition.go" },
31
+ { template: "generate/module/handler.go.hbs", output: "handler.go" },
32
+ { template: "generate/module/service_test.go.hbs", output: "service_test.go" },
33
+ { template: "generate/module/handler_test.go.hbs", output: "handler_test.go" },
34
+ { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
35
+ { template: "generate/module/cqrs_test.go.hbs", output: "cqrs_test.go" },
36
+ ];
17
37
  // minimal: same model/errors/repository (generate method's patches assume the
18
38
  // full data-access surface exists), but no default CRUD in dto/service/handler
19
39
  // — add endpoints one at a time with `generate method`.
@@ -23,8 +43,24 @@ exports.MODULE_FILES_MINIMAL = [
23
43
  { template: "generate/module/errors.go.hbs", output: "errors.go" },
24
44
  { template: "generate/module/repository.go.hbs", output: "repository.go" },
25
45
  { template: "generate/module/minimal/service.go.hbs", output: "service.go" },
46
+ { template: "generate/module/composition.go.hbs", output: "composition.go" },
47
+ { template: "generate/module/minimal/handler.go.hbs", output: "handler.go" },
48
+ { template: "generate/module/minimal/service_test.go.hbs", output: "service_test.go" },
49
+ { template: "generate/module/minimal/handler_test.go.hbs", output: "handler_test.go" },
50
+ { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
51
+ ];
52
+ exports.MODULE_FILES_MINIMAL_CQRS = [
53
+ { template: "generate/module/model/model.go.hbs", output: "model/model.go" },
54
+ { template: "generate/module/minimal/dto.go.hbs", output: "dto.go" },
55
+ { template: "generate/module/errors.go.hbs", output: "errors.go" },
56
+ { template: "generate/module/repository.go.hbs", output: "repository.go" },
57
+ { template: "generate/module/minimal/commands.go.hbs", output: "commands.go" },
58
+ { template: "generate/module/minimal/queries.go.hbs", output: "queries.go" },
59
+ { template: "generate/module/minimal/service.go.hbs", output: "service.go" },
60
+ { template: "generate/module/composition.go.hbs", output: "composition.go" },
26
61
  { template: "generate/module/minimal/handler.go.hbs", output: "handler.go" },
27
62
  { template: "generate/module/minimal/service_test.go.hbs", output: "service_test.go" },
28
63
  { template: "generate/module/minimal/handler_test.go.hbs", output: "handler_test.go" },
29
64
  { template: "generate/module/repository_test.go.hbs", output: "repository_test.go" },
65
+ { template: "generate/module/cqrs_test.go.hbs", output: "cqrs_test.go" },
30
66
  ];
@@ -12,6 +12,7 @@ exports.RBAC_FILES = [
12
12
  { template: "add/rbac/internal/app/role/service.go.hbs", output: "internal/app/role/service.go" },
13
13
  { template: "add/rbac/internal/app/role/service_test.go.hbs", output: "internal/app/role/service_test.go" },
14
14
  { template: "add/rbac/internal/app/role/repository_test.go.hbs", output: "internal/app/role/repository_test.go" },
15
+ { template: "add/rbac/internal/app/role/composition.go.hbs", output: "internal/app/role/composition.go" },
15
16
  { template: "add/rbac/internal/app/role/handler.go.hbs", output: "internal/app/role/handler.go" },
16
17
  { template: "add/rbac/internal/app/role/dto.go.hbs", output: "internal/app/role/dto.go" },
17
18
  { template: "add/rbac/internal/app/role/errors.go.hbs", output: "internal/app/role/errors.go" },
package/dist/types.js CHANGED
@@ -1,2 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_ARCHITECTURE_CONFIG = void 0;
4
+ exports.DEFAULT_ARCHITECTURE_CONFIG = {
5
+ style: "modular-monolith",
6
+ defaultModuleSurface: "minimal",
7
+ defaultApplicationStyle: "service",
8
+ };
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.patchConfigForAuth = patchConfigForAuth;
7
7
  exports.authWiringLines = authWiringLines;
8
+ exports.authHandlerLineFor = authHandlerLineFor;
8
9
  exports.patchMainGoForAuth = patchMainGoForAuth;
9
10
  exports.upgradeMailerToQueue = upgradeMailerToQueue;
10
11
  const fs_extra_1 = __importDefault(require("fs-extra"));
@@ -17,8 +18,7 @@ const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
17
18
  const SCHEMA_MARKER = "// go-scaffold:schemas";
18
19
  const MODEL_MARKER = "// go-scaffold:models";
19
20
  const ROUTE_MARKER = "// go-scaffold:routes";
20
- const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
21
- // patchConfigForAuth adds JWT/cookie/password-reset/Google OAuth fields to
21
+ // patchConfigForAuth adds JWT/cookie/password-reset/browser OAuth fields to
22
22
  // Config, the same marker-based text insertion patchConfigForWorker uses
23
23
  // (config.go.hbs is only rendered once, at `create` — everything after that
24
24
  // is a real file a human may have already edited).
@@ -28,8 +28,11 @@ function patchConfigForAuth(configGoPath) {
28
28
  "JWTSecret string",
29
29
  "JWTAccessTTL time.Duration",
30
30
  "JWTRefreshTTL time.Duration",
31
+ "JWTRefreshMaxTTL time.Duration",
32
+ "OAuthStateTTL time.Duration",
31
33
  "CookieSecure bool",
32
34
  "CookieSameSite string",
35
+ "AuthBrowserTopology string",
33
36
  "",
34
37
  "PasswordResetTTL time.Duration",
35
38
  "PasswordResetURL string",
@@ -39,15 +42,25 @@ function patchConfigForAuth(configGoPath) {
39
42
  "",
40
43
  "GoogleClientID string",
41
44
  "GoogleClientSecret string",
42
- "GoogleRedirectURL string",
45
+ "GoogleOAuthRedirectURI string",
46
+ "",
47
+ "AuthMFAEnabled bool",
48
+ "MFAIssuer string",
49
+ "MFAEncryptionKey string",
50
+ "MFAChallengeTTL time.Duration",
51
+ "MFATOTPWindow int",
52
+ "MFARecoveryCodeCount int",
43
53
  ].join("\n");
44
54
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "JWTSecret");
45
55
  const loadBlock = [
46
56
  'JWTSecret: env("JWT_SECRET", "dev-secret-change-me"),',
47
57
  'JWTAccessTTL: time.Duration(envInt("JWT_ACCESS_TTL_MIN", 15)) * time.Minute,',
48
58
  'JWTRefreshTTL: time.Duration(envInt("JWT_REFRESH_TTL_MIN", 43200)) * time.Minute,',
59
+ 'JWTRefreshMaxTTL: time.Duration(envInt("JWT_REFRESH_MAX_TTL_MIN", 43200)) * time.Minute,',
60
+ 'OAuthStateTTL: time.Duration(envInt("OAUTH_STATE_TTL_MIN", 10)) * time.Minute,',
49
61
  'CookieSecure: env("COOKIE_SECURE", "false") == "true",',
50
62
  'CookieSameSite: env("COOKIE_SAMESITE", "strict"),',
63
+ 'AuthBrowserTopology: env("AUTH_BROWSER_TOPOLOGY", "same-site"),',
51
64
  "",
52
65
  'PasswordResetTTL: time.Duration(envInt("PASSWORD_RESET_TTL_MIN", 30)) * time.Minute,',
53
66
  'PasswordResetURL: env("PASSWORD_RESET_URL", "http://localhost:3000/reset-password"),',
@@ -57,7 +70,14 @@ function patchConfigForAuth(configGoPath) {
57
70
  "",
58
71
  'GoogleClientID: env("GOOGLE_CLIENT_ID", ""),',
59
72
  'GoogleClientSecret: env("GOOGLE_CLIENT_SECRET", ""),',
60
- 'GoogleRedirectURL: env("GOOGLE_REDIRECT_URL", ""),',
73
+ 'GoogleOAuthRedirectURI: env("GOOGLE_OAUTH_REDIRECT_URI", ""),',
74
+ "",
75
+ 'AuthMFAEnabled: env("AUTH_MFA_ENABLED", "false") == "true",',
76
+ 'MFAIssuer: env("MFA_ISSUER", "go-scaffold"),',
77
+ 'MFAEncryptionKey: env("MFA_ENCRYPTION_KEY", ""),',
78
+ 'MFAChallengeTTL: time.Duration(envInt("MFA_CHALLENGE_TTL_MIN", 5)) * time.Minute,',
79
+ 'MFATOTPWindow: envInt("MFA_TOTP_WINDOW", 1),',
80
+ 'MFARecoveryCodeCount: envInt("MFA_RECOVERY_CODE_COUNT", 10),',
61
81
  ].join("\n");
62
82
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'env("JWT_SECRET"');
63
83
  fs_extra_1.default.writeFileSync(configGoPath, content);
@@ -68,13 +88,25 @@ function patchConfigForAuth(configGoPath) {
68
88
  function authWiringLines(w) {
69
89
  const postgres = w.store === "postgres";
70
90
  return {
71
- tokenStore: postgres ? "user.NewPgTokenStore(db)" : "user.NewRedisTokenStore(rdb)",
91
+ tokenStore: postgres ? "user.NewPgTokenStore(db)" : "user.NewRedisTokenStore(rdb, db)",
72
92
  limiter: postgres ? "middleware.NewMemoryLimiter()" : "middleware.NewRedisLimiter(rdb)",
73
93
  // with a queue the mail is enqueued and the request returns immediately;
74
94
  // without one it goes out inline, which is the cost of not running a worker
75
95
  mailer: w.worker ? "mail.NewAsyncClient(q)" : "mail.NewSyncClient(mail.Open(cfg))",
76
96
  };
77
97
  }
98
+ // authHandlerLineFor is the one root-level auth route shape. The feature owns
99
+ // construction; wiring.go only hands it shared infrastructure and, when RBAC
100
+ // is present, the role feature's public capabilities.
101
+ function authHandlerLineFor(w, roleDependencies = ["nil", "nil"]) {
102
+ const args = ["db", "cfg"];
103
+ if (w.store === "redis")
104
+ args.push("rdb");
105
+ if (w.worker)
106
+ args.push("q");
107
+ args.push(...roleDependencies);
108
+ return `user.NewHandlerFromDB(${args.join(", ")}).Register(api)`;
109
+ }
78
110
  function patchMainGoForAuth(mainGoPath, w) {
79
111
  const { goModule, queueBackend, store } = w;
80
112
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
@@ -86,8 +118,6 @@ function patchMainGoForAuth(mainGoPath, w) {
86
118
  const queueImportLine = `"${goModule}/internal/platform/queue"`;
87
119
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
88
120
  }
89
- const mailImportLine = `"${goModule}/internal/platform/mail"`;
90
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, mailImportLine, mailImportLine);
91
121
  const checkBlock = [
92
122
  'if cfg.IsProd() && cfg.JWTSecret == "dev-secret-change-me" {',
93
123
  '\treturn errors.New("JWT_SECRET is still the dev default — set a real secret before deploying with APP_ENV=production")',
@@ -105,6 +135,21 @@ function patchMainGoForAuth(mainGoPath, w) {
105
135
  "}",
106
136
  ].join("\n");
107
137
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, smtpCheckBlock, "SMTP_HOST is unset");
138
+ const mfaCheckBlock = [
139
+ "if cfg.AuthMFAEnabled {",
140
+ "\tif err := user.ValidateMFASettings(user.MFASettings{",
141
+ "\t\tEnabled: cfg.AuthMFAEnabled,",
142
+ "\t\tIssuer: cfg.MFAIssuer,",
143
+ "\t\tEncryptionKey: cfg.MFAEncryptionKey,",
144
+ "\t\tChallengeTTL: cfg.MFAChallengeTTL,",
145
+ "\t\tTOTPWindow: cfg.MFATOTPWindow,",
146
+ "\t\tRecoveryCodeCount: cfg.MFARecoveryCodeCount,",
147
+ "\t}); err != nil {",
148
+ "\t\treturn fmt.Errorf(\"invalid MFA configuration: %w\", err)",
149
+ "\t}",
150
+ "}",
151
+ ].join("\n");
152
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, mfaCheckBlock, "invalid MFA configuration");
108
153
  // The enqueuer is built from whatever backend `add worker` chose — the
109
154
  // constructor differs, everything downstream of it (mail.NewAsyncClient)
110
155
  // only sees the queue.Enqueuer interface and doesn't change.
@@ -120,24 +165,27 @@ function patchMainGoForAuth(mainGoPath, w) {
120
165
  ].join("\n");
121
166
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
122
167
  const migrateLines = ["&usermodel.User{},", "&usermodel.Identity{},", "&usermodel.LoginThrottle{},"];
123
- // only the Postgres store has a table for AutoMigrate to create
124
- if (store === "postgres")
125
- migrateLines.push("&usermodel.AuthToken{},");
168
+ // Recovery tokens are always durable in Postgres, even when refresh tokens
169
+ // live in Redis, so reset/verification can share the user transaction.
170
+ migrateLines.push("&usermodel.AuthToken{},");
171
+ migrateLines.push("&usermodel.MFAEnrollment{},", "&usermodel.MFAChallenge{},", "&usermodel.MFARecoveryCode{},");
126
172
  for (const line of migrateLines) {
127
173
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
128
174
  }
129
- // same two-line shape every generated module uses: a named service, then
130
- // the handler that registers it. `add rbac` extends both lines later, and a
131
- // human wiring another domain into user's service edits line one in place.
132
- const { tokenStore, limiter, mailer } = authWiringLines(w);
133
- const svcLine = `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
134
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, svcLine, "userSvc :=");
135
- const routeLine = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
175
+ // Keep auth construction inside the feature package. The root only chooses
176
+ // shared infrastructure and registers the resulting handler.
177
+ const routeLine = authHandlerLineFor(w);
136
178
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
137
179
  content = content.replace(/\n\t_ = api \/\/ dropped once `generate module` registers the first route\n/, "\n");
138
180
  if (w.worker) {
139
- const shutdownBlock = ["if err := q.Close(); err != nil {", '\tlogger.Error("close queue", "error", err)', "}"].join("\n");
140
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := q.Close()");
181
+ const cleanupBlock = [
182
+ "defer func() {",
183
+ '\tif err := q.Close(); err != nil {',
184
+ '\t\tlogger.Error("close queue", "error", err)',
185
+ "\t}",
186
+ "}()",
187
+ ].join("\n");
188
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, cleanupBlock, "defer func() {\n\tif err := q.Close()");
141
189
  }
142
190
  fs_extra_1.default.writeFileSync(mainGoPath, content);
143
191
  }
@@ -153,19 +201,62 @@ function patchMainGoForAuth(mainGoPath, w) {
153
201
  // at all — both simply don't contain the line it looks for. Returns whether
154
202
  // it actually upgraded something, so the caller's printed summary can say
155
203
  // which happened instead of always assuming "no auth yet".
156
- function upgradeMailerToQueue(mainGoPath, goModule, queueBackend) {
204
+ function upgradeMailerToQueue(mainGoPath, goModule, queueBackend, compositionGoPath) {
157
205
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
158
206
  const syncMailer = "mail.NewSyncClient(mail.Open(cfg))";
159
- if (!content.includes(syncMailer))
207
+ let upgradedComposition = false;
208
+ // Auth now keeps mailer construction in internal/app/user. A worker added
209
+ // later must upgrade that feature-local composition and thread q into the
210
+ // existing root registration, rather than moving construction back to the
211
+ // composition root.
212
+ if (compositionGoPath && fs_extra_1.default.existsSync(compositionGoPath)) {
213
+ let composition = fs_extra_1.default.readFileSync(compositionGoPath, "utf8");
214
+ if (composition.includes(syncMailer)) {
215
+ composition = (0, marker_patch_1.ensureImport)(composition, `${goModule}/internal/platform/queue`);
216
+ if (!composition.includes("q queue.Enqueuer")) {
217
+ const configParam = "\tcfg config.Config,\n";
218
+ const redisParam = "\trdb *redis.Client,\n";
219
+ const queueAnchor = composition.includes(redisParam) ? redisParam : configParam;
220
+ if (!composition.includes(queueAnchor)) {
221
+ throw new Error(`${compositionGoPath} is missing the cfg parameter needed to upgrade auth mail to a queue`);
222
+ }
223
+ composition = composition.replace(queueAnchor, `${queueAnchor}\tq queue.Enqueuer,\n`);
224
+ }
225
+ composition = composition.replace(syncMailer, "mail.NewAsyncClient(q)");
226
+ fs_extra_1.default.writeFileSync(compositionGoPath, composition);
227
+ upgradedComposition = true;
228
+ const routeMatch = content.match(/user\.NewHandlerFromDB\(db, cfg, ([^)]*)\)\.Register\(api\)/);
229
+ if (!routeMatch) {
230
+ throw new Error("cmd/api/wiring.go is missing auth's feature-local NewHandlerFromDB route while adding the worker");
231
+ }
232
+ const args = routeMatch[1].split(",").map((arg) => arg.trim());
233
+ if (!args.includes("q")) {
234
+ if (args.length < 2)
235
+ throw new Error("auth's NewHandlerFromDB route has no role/authz dependency slots");
236
+ args.splice(args.length - 2, 0, "q");
237
+ content = content.replace(routeMatch[0], `user.NewHandlerFromDB(db, cfg, ${args.join(", ")}).Register(api)`);
238
+ }
239
+ }
240
+ }
241
+ // Keep the fallback for projects scaffolded before feature-local auth
242
+ // composition existed. New projects take the branch above.
243
+ if (!upgradedComposition && !content.includes(syncMailer))
160
244
  return false;
161
245
  const queueImportLine = `"${goModule}/internal/platform/queue"`;
162
246
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
163
247
  const queueCtor = queueBackend === "river" ? "queue.NewRiverEnqueuer(db)" : "queue.NewAsynqEnqueuer(cfg.RedisURL)";
164
248
  const queueInitBlock = [`q, err := ${queueCtor}`, "if err != nil {", '\treturn fmt.Errorf("open queue: %w", err)', "}"].join("\n");
165
249
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, queueInitBlock, "q, err := queue.New");
166
- const shutdownBlock = ["if err := q.Close(); err != nil {", '\tlogger.Error("close queue", "error", err)', "}"].join("\n");
167
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := q.Close()");
168
- content = content.replace(syncMailer, () => "mail.NewAsyncClient(q)");
250
+ const cleanupBlock = [
251
+ "defer func() {",
252
+ '\tif err := q.Close(); err != nil {',
253
+ '\t\tlogger.Error("close queue", "error", err)',
254
+ "\t}",
255
+ "}()",
256
+ ].join("\n");
257
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, cleanupBlock, "defer func() {\n\tif err := q.Close()");
258
+ if (!upgradedComposition)
259
+ content = content.replace(syncMailer, () => "mail.NewAsyncClient(q)");
169
260
  fs_extra_1.default.writeFileSync(mainGoPath, content);
170
261
  return true;
171
262
  }