@nakedev/go-scaffold 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.patchComposeForRedis = patchComposeForRedis;
7
7
  exports.patchCiForRedis = patchCiForRedis;
8
+ exports.patchCiForRiver = patchCiForRiver;
8
9
  exports.patchConfigForRedis = patchConfigForRedis;
9
10
  exports.patchConfigForWorker = patchConfigForWorker;
10
11
  exports.patchConfigForSMTP = patchConfigForSMTP;
@@ -17,7 +18,6 @@ const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
17
18
  const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
18
19
  const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
19
20
  const READYZ_MARKER = "// go-scaffold:readyz-checks";
20
- const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
21
21
  // patchComposeForRedis adds a redis service to docker-compose.yml. Whatever
22
22
  // pulls Redis in — `add worker --queue redis`, or `add auth`'s refresh-token
23
23
  // store on top of a Postgres queue — makes cmd/api call cache.Open and adds a
@@ -55,8 +55,7 @@ function patchCiForRedis(ciPath) {
55
55
  if (!fs_extra_1.default.existsSync(ciPath))
56
56
  return;
57
57
  const content = fs_extra_1.default.readFileSync(ciPath, "utf8");
58
- if (/^\s{6}redis:/m.test(content))
59
- return;
58
+ const hasRedisService = /^\s{6}redis:/m.test(content);
60
59
  // `steps:` sits one level under the job, so it's the first line that ends
61
60
  // the `services:` block — insert the service just above it.
62
61
  //
@@ -65,7 +64,7 @@ function patchCiForRedis(ciPath) {
65
64
  // means CI runs without Redis and fails on a connection error that says
66
65
  // nothing about this — so say it here instead.
67
66
  const stepsLine = content.split("\n").find((l) => l.trimEnd() === " steps:");
68
- if (!stepsLine) {
67
+ if (!hasRedisService && !stepsLine) {
69
68
  console.error(picocolors_1.default.yellow(`skipped adding the Redis service to ${ciPath} — no \`steps:\` line at the expected indentation to anchor it to.\n` +
70
69
  `Add a redis service to the workflow's \`services:\` block by hand, or CI will run without one.`));
71
70
  return;
@@ -82,7 +81,51 @@ function patchCiForRedis(ciPath) {
82
81
  " --health-retries 5",
83
82
  "",
84
83
  ].join("\n");
85
- fs_extra_1.default.writeFileSync(ciPath, content.replace(stepsLine, () => `${service}\n${stepsLine}`));
84
+ let patched = hasRedisService ? content : content.replace(stepsLine, () => `${service}\n${stepsLine}`);
85
+ // The service alone is not enough: token-store tests deliberately skip when
86
+ // TEST_REDIS_URL is absent, so a generated workflow could report green while
87
+ // never exercising Redis. Add the required test variables alongside the
88
+ // existing PostgreSQL test variables, anchored on the actual go test line so
89
+ // we do not accidentally put them in the Postgres container's env block.
90
+ const testRunLine = " run: go test ./...";
91
+ if (!patched.includes("REQUIRE_TEST_REDIS")) {
92
+ if (!patched.includes(testRunLine)) {
93
+ console.error(picocolors_1.default.yellow(`skipped requiring Redis integration tests in ${ciPath} — no \`run: go test ./...\` line to anchor the test environment to.\n` +
94
+ `Add TEST_REDIS_URL and REQUIRE_TEST_REDIS=true to the workflow's test step by hand.`));
95
+ }
96
+ else {
97
+ patched = patched.replace(testRunLine, ' TEST_REDIS_URL: redis://127.0.0.1:6379/0\n' +
98
+ ' REQUIRE_TEST_REDIS: "true"\n' +
99
+ testRunLine);
100
+ }
101
+ }
102
+ fs_extra_1.default.writeFileSync(ciPath, patched);
103
+ }
104
+ // patchCiForRiver makes the generated worker round-trip test runnable in CI.
105
+ // River's schema is versioned by River itself, separately from this project's
106
+ // migrations/, so applying only the application migrations leaves the test
107
+ // unable to exercise the worker.
108
+ function patchCiForRiver(ciPath) {
109
+ if (!fs_extra_1.default.existsSync(ciPath))
110
+ return;
111
+ const content = fs_extra_1.default.readFileSync(ciPath, "utf8");
112
+ if (content.includes("river-migrate-test") || content.includes("Apply River migrations"))
113
+ return;
114
+ const testStep = content.split("\n").find((line) => line.trimStart().startsWith("- name: Test (required PostgreSQL integration tests cannot skip)"));
115
+ const dsn = content.match(/^\s+DB_DSN:\s+(.+)$/m)?.[1]?.trim();
116
+ if (!testStep || !dsn) {
117
+ console.error(picocolors_1.default.yellow(`skipped adding River migrations to ${ciPath} — the generated migration DSN or PostgreSQL test step is missing.\n` +
118
+ `Add a River migration step before the required PostgreSQL test step by hand.`));
119
+ return;
120
+ }
121
+ const step = [
122
+ " - name: Apply River migrations to the test database",
123
+ " env:",
124
+ ` TEST_DB_DSN: ${dsn}`,
125
+ ' run: go run github.com/riverqueue/river/cmd/river@v0.43.0 migrate-up --line main --database-url "$TEST_DB_DSN"',
126
+ "",
127
+ ].join("\n");
128
+ fs_extra_1.default.writeFileSync(ciPath, content.replace(testStep, `${step}${testStep}`));
86
129
  }
87
130
  // patchConfigForRedis adds RedisURL to Config and its env() load to Load().
88
131
  // Split out from the worker patch because Redis is no longer the worker's
@@ -141,6 +184,14 @@ function patchMainGoForWorker(mainGoPath, goModule) {
141
184
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, cacheImport, cacheImport);
142
185
  const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\treturn fmt.Errorf("open redis: %w", err)', "}"].join("\n");
143
186
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, initBlock, "rdb, err := cache.Open(cfg)");
187
+ const cleanupBlock = [
188
+ "defer func() {",
189
+ '\tif err := rdb.Close(); err != nil {',
190
+ '\t\tlogger.Error("close redis", "error", err)',
191
+ "\t}",
192
+ "}()",
193
+ ].join("\n");
194
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, cleanupBlock, "defer func() {\n\tif err := rdb.Close()");
144
195
  const readyzBlock = [
145
196
  "if err := rdb.Ping(c.Request.Context()).Err(); err != nil {",
146
197
  '\tc.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})',
@@ -148,7 +199,5 @@ function patchMainGoForWorker(mainGoPath, goModule) {
148
199
  "}",
149
200
  ].join("\n");
150
201
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, READYZ_MARKER, readyzBlock, "if err := rdb.Ping(");
151
- const shutdownBlock = ["if err := rdb.Close(); err != nil {", '\tlogger.Error("close redis", "error", err)', "}"].join("\n");
152
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SHUTDOWN_MARKER, shutdownBlock, "if err := rdb.Close()");
153
202
  fs_extra_1.default.writeFileSync(mainGoPath, content);
154
203
  }
@@ -13,7 +13,6 @@ exports.patchUserServiceTestForRbac = patchUserServiceTestForRbac;
13
13
  exports.patchUserDTOForRbac = patchUserDTOForRbac;
14
14
  exports.patchUserHandlerForRbac = patchUserHandlerForRbac;
15
15
  exports.patchUserErrorsForRbac = patchUserErrorsForRbac;
16
- exports.userSvcLineFor = userSvcLineFor;
17
16
  exports.assertRbacPatchable = assertRbacPatchable;
18
17
  exports.patchMainGoForRbac = patchMainGoForRbac;
19
18
  exports.patchCmdSeedForRbac = patchCmdSeedForRbac;
@@ -23,13 +22,12 @@ const marker_patch_1 = require("./marker-patch");
23
22
  const IMPORT_MARKER = "// go-scaffold:imports";
24
23
  const SCHEMA_MARKER = "// go-scaffold:schemas";
25
24
  const MODEL_MARKER = "// go-scaffold:models";
26
- const ROUTE_MARKER = "// go-scaffold:routes";
27
25
  const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
28
26
  const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
29
27
  // patchAuthDocsForRbac adds `role` to the hand-written MeResponse schema in
30
28
  // docs/auth/schemas.yaml — same marker convention as the Go dto.go patch
31
29
  // (patchUserDTOForRbac), since GET/PATCH /users(/me) all serialize the same
32
- // model.User whether or not RBAC is installed.
30
+ // user response whether or not RBAC is installed.
33
31
  function patchAuthDocsForRbac(authSchemasYamlPath) {
34
32
  if (!fs_extra_1.default.existsSync(authSchemasYamlPath))
35
33
  return; // openapi docs feature disabled
@@ -46,15 +44,16 @@ function patchConfigForRbac(configGoPath) {
46
44
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, 'AuthzCacheTTL: time.Duration(envInt("AUTHZ_CACHE_TTL_MIN", 1)) * time.Minute,', "AuthzCacheTTL: time.Duration");
47
45
  fs_extra_1.default.writeFileSync(configGoPath, content);
48
46
  }
49
- // patchUserModelForRbac adds the Role column to model.User default 'staff'
50
- // at the DB level (via the gorm tag), so every existing user-creation path
51
- // (Register, Google, cmd/seed) gets a sane role without each one needing to
52
- // set it explicitly.
47
+ // patchUserModelForRbac verifies the auth template exposes the optional role
48
+ // field in its outbound persistence model. Auth owns the stable user shape;
49
+ // RBAC adds the role catalog and authorization policy on top of it. Keeping
50
+ // this check idempotent lets the command work across scaffold versions
51
+ // without recreating a second model package.
53
52
  function patchUserModelForRbac(userModelPath) {
54
53
  let content = fs_extra_1.default.readFileSync(userModelPath, "utf8");
55
- const field = `Role string \`json:"role" gorm:"type:varchar(20);not null;default:'staff'"\``;
56
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-fields", field, "Role string");
57
- fs_extra_1.default.writeFileSync(userModelPath, content);
54
+ if (/\bRole\s+string\b/.test(content))
55
+ return;
56
+ throw new Error(`${userModelPath} does not expose User.Role — the auth module must be regenerated or migrated before adding RBAC`);
58
57
  }
59
58
  // patchMiddlewareAuthForRbac adds RoleKey + the Role claim to the shared
60
59
  // middleware's own accessClaims copy (see auth.go's own comment on why it's
@@ -78,189 +77,88 @@ function patchUserJWTForRbac(jwtGoPath) {
78
77
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:jwt-claims-values", "Role: role,", "Role: role,");
79
78
  fs_extra_1.default.writeFileSync(jwtGoPath, content);
80
79
  }
81
- // patchUserServiceForRbac wires a role.Service dependency into user.Service
82
- // (via a consumer-side roleChecker interface, same convention as
83
- // repository/mailer/tokenStore) and adds SetRole, the one place a user's
84
- // role actually changes after creation.
85
- function patchUserServiceForRbac(serviceGoPath) {
86
- let content = fs_extra_1.default.readFileSync(serviceGoPath, "utf8");
87
- const roleCheckerInterface = [
88
- "// roleChecker = what the service needs from the role domain to validate a",
89
- "// role assignment — declared consumer-side, same convention as repository/mailer.",
90
- "type roleChecker interface {",
91
- "\tCodeExists(ctx context.Context, code string) (bool, error)",
92
- "}",
93
- ].join("\n");
94
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-interfaces", roleCheckerInterface, "type roleChecker interface");
95
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-fields", "roles roleChecker", "roles roleChecker");
96
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-params", "roles roleChecker,", "roles roleChecker,");
97
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-init", "roles: roles,", "roles: roles,");
98
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:issue-access-token-args", "u.Role,", "u.Role,");
99
- const setRoleMethod = [
100
- "// SetRole assigns userID a new role — validated against the role catalog",
101
- "// (not just the DB's FK) so a typo'd code fails with a clear 422 here",
102
- "// instead of surfacing as an opaque FK-violation, and so AutoMigrate-only",
103
- "// test schemas (which never create the FK) still reject it correctly.",
104
- "func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string) (*model.User, error) {",
105
- "\tu, err := s.repo.FindByID(ctx, userID)",
106
- "\tif err != nil {",
107
- "\t\tif errors.Is(err, gorm.ErrRecordNotFound) {",
108
- "\t\t\treturn nil, errNotFound()",
109
- "\t\t}",
110
- "\t\treturn nil, apperror.NewInternal()",
111
- "\t}",
112
- "\texists, err := s.roles.CodeExists(ctx, roleCode)",
113
- "\tif err != nil {",
114
- "\t\treturn nil, apperror.NewInternal()",
115
- "\t}",
116
- "\tif !exists {",
117
- "\t\treturn nil, errUnknownRole()",
118
- "\t}",
119
- "\tu.Role = roleCode",
120
- "\tif err := s.repo.UpdateUser(ctx, u); err != nil {",
121
- "\t\treturn nil, apperror.NewInternal()",
122
- "\t}",
123
- "\treturn u, nil",
124
- "}",
125
- ].join("\n");
126
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-methods", setRoleMethod, "func (s *Service) SetRole(");
127
- fs_extra_1.default.writeFileSync(serviceGoPath, content);
80
+ // patchUserServiceForRbac verifies the auth application already exposes the
81
+ // optional role capability. The canonical auth template owns this shared user
82
+ // behavior; `add rbac` only supplies the role catalog and authorizer.
83
+ function patchUserServiceForRbac(serviceGoPath, sessionsGoPath) {
84
+ const content = fs_extra_1.default.readFileSync(serviceGoPath, "utf8");
85
+ for (const required of ["roles ports.RoleChecker", "func (s *Service) SetRole(", "// go-scaffold:service-interface"]) {
86
+ if (!content.includes(required)) {
87
+ throw new Error(`${serviceGoPath} is missing the canonical auth role capability (${required}); regenerate auth before adding RBAC`);
88
+ }
89
+ }
90
+ if (sessionsGoPath && fs_extra_1.default.existsSync(sessionsGoPath)) {
91
+ const sessions = fs_extra_1.default.readFileSync(sessionsGoPath, "utf8");
92
+ if (!sessions.includes("u.Role,")) {
93
+ throw new Error(`${sessionsGoPath} does not include the user's role in access-token issuance; regenerate auth before adding RBAC`);
94
+ }
95
+ }
128
96
  }
129
- // patchUserServiceTestForRbac keeps service_test.go's single NewService call
130
- // site (newTestService) compiling once patchUserServiceForRbac adds the
131
- // roleChecker param above same drift risk the handoff notes called out for
132
- // middleware.NewAuthz's call sites, just for this signature instead. The fake
133
- // itself is added here too, not pre-declared unconditionally in the
134
- // template: golangci-lint's unused check flags an unreferenced type+method
135
- // pair in the auth-only (pre-rbac) state, since nothing there yet implements
136
- // or needs a roleChecker.
97
+ // patchUserServiceTestForRbac verifies the canonical auth test seam already
98
+ // supplies the optional role capability. It is part of the auth contract so
99
+ // adding RBAC does not rewrite every test call site.
137
100
  function patchUserServiceTestForRbac(serviceTestGoPath) {
138
- let content = fs_extra_1.default.readFileSync(serviceTestGoPath, "utf8");
139
- const fakeRoles = [
140
- "// fakeRoles satisfies role's roleChecker interface structurally.",
141
- "type fakeRoles struct{}",
142
- "",
143
- "func (fakeRoles) CodeExists(context.Context, string) (bool, error) { return true, nil }",
144
- ].join("\n");
145
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-types", fakeRoles, "type fakeRoles struct{}");
146
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-args", "fakeRoles{},", "fakeRoles{},");
147
- fs_extra_1.default.writeFileSync(serviceTestGoPath, content);
101
+ const content = fs_extra_1.default.readFileSync(serviceTestGoPath, "utf8");
102
+ for (const required of ["type fakeRoles struct{}", "Roles: fakeRoles{},", "// go-scaffold:user-service-test-deps"]) {
103
+ if (!content.includes(required)) {
104
+ throw new Error(`${serviceTestGoPath} is missing the canonical auth role test seam (${required}); regenerate auth before adding RBAC`);
105
+ }
106
+ }
148
107
  }
149
- // patchUserDTOForRbac adds the request DTO for PATCH /users/:id/set-role and
150
- // surfaces Role on the /me response both gated behind RBAC since
151
- // model.User has no Role field at all without it.
108
+ // patchUserDTOForRbac verifies the role DTOs owned by the canonical auth
109
+ // inbound adapter. The routes remain inactive until an Authz is composed.
152
110
  function patchUserDTOForRbac(dtoGoPath) {
153
- let content = fs_extra_1.default.readFileSync(dtoGoPath, "utf8");
154
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-me-fields", 'Role string `json:"role"`', "Role string");
155
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-me-values", "Role: u.Role,", "Role: u.Role,");
156
- const setRoleInput = ["type setRoleInput struct {", '\tRole string `json:"role" binding:"required"`', "}"].join("\n");
157
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-dto", setRoleInput, "type setRoleInput struct");
158
- fs_extra_1.default.writeFileSync(dtoGoPath, content);
111
+ const content = fs_extra_1.default.readFileSync(dtoGoPath, "utf8");
112
+ if (!/\bRole\s+string\s+`json:"role"`/.test(content)) {
113
+ throw new Error(`${dtoGoPath} is missing the canonical auth role DTO; regenerate auth before adding RBAC`);
114
+ }
115
+ if (!/Role:\s+user\.Role,/.test(content)) {
116
+ throw new Error(`${dtoGoPath} is missing the canonical auth role DTO mapping; regenerate auth before adding RBAC`);
117
+ }
118
+ if (!content.includes("type setRoleInput struct")) {
119
+ throw new Error(`${dtoGoPath} is missing the canonical auth role DTO (type setRoleInput struct); regenerate auth before adding RBAC`);
120
+ }
159
121
  }
160
- // patchUserHandlerForRbac wires an *middleware.Authz into Handler and adds
161
- // the admin routes this PR ships: listing/viewing other users (PermUserRead)
162
- // and changing a user's role (PermUserManageRole). Suspending a user is a
163
- // separate concern, not RBAC's — see the role domain's own /roles,
164
- // /permissions for the actual role/permission management API.
165
- function patchUserHandlerForRbac(handlerGoPath, goModule) {
166
- let content = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
167
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserManageRole = "user:manage-role"', "PermUserManageRole");
168
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserRead = "user:read"', "PermUserRead");
169
- const paginationImport = `"${goModule}/internal/shared/pagination"`;
170
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-imports", paginationImport, "internal/shared/pagination");
171
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-fields", "authz *middleware.Authz", "authz *middleware.Authz");
172
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-params", "authz *middleware.Authz,", "authz *middleware.Authz,");
173
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-init", "authz: authz,", "authz: authz,");
174
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-routes", 'usersGroup.GET("", h.authz.Require(PermUserRead), h.adminListUsers)', 'usersGroup.GET("", h.authz.Require(PermUserRead)');
175
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-routes", 'usersGroup.GET("/:id", h.authz.Require(PermUserRead), h.adminGetUser)', 'usersGroup.GET("/:id", h.authz.Require(PermUserRead)');
176
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-routes", 'usersGroup.PATCH("/:id/set-role", h.authz.Require(PermUserManageRole), h.setRole)', 'usersGroup.PATCH("/:id/set-role"');
177
- const adminListHandler = [
178
- "func (h *Handler) adminListUsers(c *gin.Context) {",
179
- "\tp := pagination.Parse(c)",
180
- "\titems, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)",
181
- "\tif err != nil {",
182
- "\t\tc.Error(err)",
183
- "\t\treturn",
184
- "\t}",
185
- "\tout := make([]meResponse, len(items))",
186
- "\tfor i := range items {",
187
- "\t\tout[i] = toMeResponse(&items[i])",
188
- "\t}",
189
- "\tc.JSON(http.StatusOK, p.Response(out))",
190
- "}",
191
- ].join("\n");
192
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-funcs", adminListHandler, "func (h *Handler) adminListUsers(");
193
- const adminGetHandler = [
194
- "func (h *Handler) adminGetUser(c *gin.Context) {",
195
- "\tid, ok := httpx.ParseID(c)",
196
- "\tif !ok {",
197
- "\t\treturn",
198
- "\t}",
199
- "\tu, err := h.svc.Get(c.Request.Context(), id)",
200
- "\tif err != nil {",
201
- "\t\tc.Error(err)",
202
- "\t\treturn",
203
- "\t}",
204
- "\tc.JSON(http.StatusOK, toMeResponse(u))",
205
- "}",
206
- ].join("\n");
207
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-funcs", adminGetHandler, "func (h *Handler) adminGetUser(");
208
- const setRoleHandler = [
209
- "func (h *Handler) setRole(c *gin.Context) {",
210
- "\tid, ok := httpx.ParseID(c)",
211
- "\tif !ok {",
212
- "\t\treturn",
213
- "\t}",
214
- "\tvar in setRoleInput",
215
- "\tif err := c.ShouldBindJSON(&in); err != nil {",
216
- "\t\tc.Error(httpx.BindErr(err))",
217
- "\t\treturn",
218
- "\t}",
219
- "\tu, err := h.svc.SetRole(c.Request.Context(), id, in.Role)",
220
- "\tif err != nil {",
221
- "\t\tc.Error(err)",
222
- "\t\treturn",
223
- "\t}",
224
- "\tc.JSON(http.StatusOK, toMeResponse(u))",
225
- "}",
226
- ].join("\n");
227
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-funcs", setRoleHandler, "func (h *Handler) setRole(");
228
- fs_extra_1.default.writeFileSync(handlerGoPath, content);
122
+ // patchUserHandlerForRbac verifies that auth owns the role-aware admin route
123
+ // surface. The routes are guarded by the optional authorizer, so adding RBAC
124
+ // composes the guard; it does not copy or rewrite handler code.
125
+ function patchUserHandlerForRbac(handlerGoPath) {
126
+ const content = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
127
+ for (const required of [
128
+ "authz authorizer",
129
+ "func (h *Handler) adminListUsers(",
130
+ "func (h *Handler) adminGetUser(",
131
+ "func (h *Handler) setRole(",
132
+ 'usersGroup.GET("", h.authz.Require(PermUserRead), h.adminListUsers)',
133
+ 'usersGroup.GET("/:id", h.authz.Require(PermUserRead), h.adminGetUser)',
134
+ 'usersGroup.PATCH("/:id/set-role", h.authz.Require(PermUserManageRole), h.setRole)',
135
+ ]) {
136
+ if (!content.includes(required)) {
137
+ throw new Error(`${handlerGoPath} is missing the canonical auth role route (${required}); regenerate auth before adding RBAC`);
138
+ }
139
+ }
229
140
  }
230
141
  function patchUserErrorsForRbac(errorsGoPath) {
231
- let content = fs_extra_1.default.readFileSync(errorsGoPath, "utf8");
232
- const errFn = ["func errUnknownRole() *apperror.AppError {", '\treturn apperror.New(http.StatusUnprocessableEntity, "USER_UNKNOWN_ROLE", "unknown role code")', "}"].join("\n");
233
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-errors", errFn, "func errUnknownRole(");
234
- fs_extra_1.default.writeFileSync(errorsGoPath, content);
235
- }
236
- // patchMainGoForRbac wires the role domain into cmd/api: its import, its
237
- // three models in the AutoMigrate call, and — the one place a plain insert
238
- // isn't enough — REPLACES the `add auth` PR's user.NewHandler(...) call with
239
- // a version that also builds roleSvc/authz and passes them through, plus
240
- // registers role's own routes right after it.
241
- // userSvcLineFor rebuilds the exact line `add auth` wrote, from the same
242
- // helper it used. Exported so the command can check for it *before* it starts
243
- // patching: every other file rbac touches is edited first, and until this
244
- // existed a mismatch here threw after user.NewService had already grown a
245
- // roleChecker parameter — leaving a project that no longer compiled and an
246
- // error telling you to restore a line that wouldn't have fixed it.
247
- function userSvcLineFor(goModule, store, worker) {
248
- const { tokenStore, mailer } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
249
- return `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
142
+ const content = fs_extra_1.default.readFileSync(errorsGoPath, "utf8");
143
+ if (!content.includes("func errUnknownRole(")) {
144
+ throw new Error(`${errorsGoPath} is missing the canonical unknown-role error; regenerate auth before adding RBAC`);
145
+ }
250
146
  }
251
147
  function assertRbacPatchable(mainGoPath, goModule, store, worker) {
252
- const expected = userSvcLineFor(goModule, store, worker);
253
- if (fs_extra_1.default.readFileSync(mainGoPath, "utf8").includes(expected))
148
+ const wiring = { goModule, queueBackend: "river", store, worker };
149
+ const content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
150
+ const expected = (0, auth_patcher_1.authHandlerLineFor)(wiring);
151
+ if (content.includes(expected))
254
152
  return;
255
- throw new Error("cmd/api/wiring.go's userSvc line doesn't match what `add auth` wrote, so `add rbac` can't extend it.\n" +
153
+ throw new Error("cmd/api/wiring.go's auth route doesn't match what `add auth` wrote, so `add rbac` can't extend it.\n" +
256
154
  `Expected to find:\n ${expected}\n\n` +
257
- "It was probably hand-edited. Restore that line and re-run — nothing has been changed yet.");
155
+ "It was probably hand-edited. Restore that route and re-run — nothing has been changed yet.");
258
156
  }
259
157
  function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
260
158
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
261
159
  const importLine = `"${goModule}/internal/app/role"`;
262
160
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
263
- const modelImportLine = `rolemodel "${goModule}/internal/app/role/model"`;
161
+ const modelImportLine = `rolepostgres "${goModule}/internal/app/role/adapters/outbound/postgres"`;
264
162
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
265
163
  const schemaBlock = [
266
164
  'if err := db.Exec("CREATE SCHEMA IF NOT EXISTS role_svc").Error; err != nil {',
@@ -268,41 +166,21 @@ function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
268
166
  "}",
269
167
  ].join("\n");
270
168
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS role_svc");
271
- const migrateLines = ["&rolemodel.Role{},", "&rolemodel.Permission{},", "&rolemodel.RolePermission{},"];
169
+ const migrateLines = ["&rolepostgres.Role{},", "&rolepostgres.Permission{},", "&rolepostgres.RolePermission{},"];
272
170
  for (const line of migrateLines) {
273
171
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
274
172
  }
275
- // roleSvc and authz have to be declared *above* userSvc, which now takes
276
- // roleSvc so this rewrites the service line in place rather than
277
- // appending at the marker (which would land below it).
278
- // Rebuilt from the same helper `add auth` used, so a project on either store
279
- // gets its own line matched rather than a hardcoded guess at one of them.
280
- const { limiter } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
281
- const userSvcLine = userSvcLineFor(goModule, store, worker);
282
- // Throw rather than skip: the roleSvc/authz declarations this rewrite adds
283
- // are what the unconditional patches below refer to. Skipping quietly still
284
- // emits `roleSvc`/`authz` references with nothing declaring them, so the
285
- // command reports success over a main.go that doesn't compile.
286
- if (!content.includes(userSvcLine)) {
287
- throw new Error(`cmd/api/wiring.go's userSvc line doesn't match what \`add auth\` wrote, so \`add rbac\` can't extend it.\n` +
288
- `Expected to find:\n ${userSvcLine}\n\n` +
289
- `It was probably hand-edited. Restore that line (add rbac will re-extend it), or apply the rbac wiring by hand:\n` +
290
- ` roleSvc := role.NewService(role.NewRepository(db))\n` +
291
- ` authz := middleware.NewAuthz(roleSvc.PermissionsOf, cfg.AuthzCacheTTL)\n` +
292
- ` ...then pass roleSvc as user.NewService's last argument.`);
173
+ const wiring = { goModule, queueBackend: "river", store, worker };
174
+ const authRouteLine = (0, auth_patcher_1.authHandlerLineFor)(wiring);
175
+ const roleCompositionLine = "roleComposition := role.NewCompositionFromDB(db, cfg.JWTSecret, cfg.AuthzCacheTTL)";
176
+ const roleHandlerLine = "roleComposition.Handler.Register(api)";
177
+ if (!content.includes(authRouteLine)) {
178
+ throw new Error(`cmd/api/wiring.go's auth route doesn't match what \`add auth\` wrote, so \`add rbac\` can't extend it.\n` +
179
+ `Expected to find:\n ${authRouteLine}\n\n` +
180
+ "It was probably hand-edited. Restore that route and re-run nothing has been changed yet.");
293
181
  }
294
- content = content.replace(userSvcLine, [
295
- "roleSvc := role.NewService(role.NewRepository(db))",
296
- "authz := middleware.NewAuthz(roleSvc.PermissionsOf, cfg.AuthzCacheTTL)",
297
- `${userSvcLine.slice(0, -1)}, roleSvc)`,
298
- ].join("\n"));
299
- const userRouteLine = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
300
- // strip the trailing `).Register(api)` — not just `.Register(api)` — so authz
301
- // lands inside NewHandler's argument list rather than after its closing paren
302
- const tail = ").Register(api)";
303
- content = content.replace(userRouteLine, `${userRouteLine.slice(0, -tail.length)}, authz${tail}`);
304
- const roleRouteLine = "role.NewHandler(roleSvc, cfg.JWTSecret, authz).Register(api)";
305
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, roleRouteLine, roleRouteLine);
182
+ const authWithRoleLine = (0, auth_patcher_1.authHandlerLineFor)(wiring, ["roleComposition.Service", "roleComposition.Authz"]);
183
+ content = content.replace(authRouteLine, [roleCompositionLine, authWithRoleLine, roleHandlerLine].join("\n"));
306
184
  fs_extra_1.default.writeFileSync(mainGoPath, content);
307
185
  }
308
186
  // patchCmdSeedForRbac makes the seeded admin actually an admin: wires a
@@ -313,9 +191,10 @@ function patchCmdSeedForRbac(seedMainGoPath, goModule) {
313
191
  let content = fs_extra_1.default.readFileSync(seedMainGoPath, "utf8");
314
192
  const importLine = `"${goModule}/internal/app/role"`;
315
193
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-imports", importLine, importLine);
316
- const roleSvcLine = "roleSvc := role.NewService(role.NewRepository(db))";
194
+ const roleSvcLine = "roleSvc := role.NewServiceFromDB(db)";
317
195
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-services", roleSvcLine, roleSvcLine);
318
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-user-service-args", "roleSvc,", "roleSvc,");
196
+ const seedRoleDependency = content.includes("user.Dependencies{") ? "Roles: roleSvc," : "roleSvc,";
197
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-user-service-args", seedRoleDependency, seedRoleDependency);
319
198
  const setRoleCall = [
320
199
  'if _, err := svc.SetRole(ctx, u.ID, "admin"); err != nil {',
321
200
  '\tlogger.Error("promote seed admin to admin role", "error", err)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,9 +23,13 @@
23
23
  "test:unit": "node --test tests/unit/*.test.mjs",
24
24
  "test:integration": "node --test tests/integration/*.test.mjs",
25
25
  "test:smoke": "node scripts/smoke-test.mjs",
26
+ "test:e2e": "playwright test --config=playwright.config.mjs",
27
+ "test:e2e:install": "playwright install chromium",
26
28
  "verify": "pnpm run build && pnpm run test",
29
+ "release:check": "node scripts/check-release.mjs",
30
+ "policy:main": "node scripts/check-main-merge.mjs",
27
31
  "prepack": "rm -rf dist && pnpm run build",
28
- "prepublishOnly": "pnpm run verify"
32
+ "prepublishOnly": "pnpm run release:check && pnpm run verify"
29
33
  },
30
34
  "keywords": [
31
35
  "go",
@@ -49,6 +53,7 @@
49
53
  "pluralize": "^8.0.0"
50
54
  },
51
55
  "devDependencies": {
56
+ "@playwright/test": "1.55.0",
52
57
  "@types/fs-extra": "^11.0.4",
53
58
  "@types/node": "^24.0.0",
54
59
  "@types/pluralize": "^0.0.33",
@@ -7,6 +7,7 @@ import (
7
7
  "os"
8
8
 
9
9
  "{{goModule}}/internal/app/user"
10
+ userpassword "{{goModule}}/internal/app/user/adapters/outbound/password"
10
11
  "{{goModule}}/internal/platform/database"
11
12
  "{{goModule}}/internal/shared/config"
12
13
  // go-scaffold:seed-imports
@@ -30,10 +31,21 @@ func main() {
30
31
  }
31
32
 
32
33
  // go-scaffold:seed-services
33
- svc := user.NewService(
34
- user.NewRepository(db), nil, nil, cfg,
34
+ svc := user.NewService(user.Dependencies{
35
+ Repository: user.NewRepository(db),
36
+ Passwords: userpassword.NewBCryptHasher(),
35
37
  // go-scaffold:seed-user-service-args
36
- )
38
+ }, user.AuthConfig{
39
+ JWTSecret: cfg.JWTSecret,
40
+ JWTAccessTTL: cfg.JWTAccessTTL,
41
+ JWTRefreshTTL: cfg.JWTRefreshTTL,
42
+ JWTRefreshMaxTTL: cfg.JWTRefreshMaxTTL,
43
+ OAuthStateTTL: cfg.OAuthStateTTL,
44
+ PasswordResetTTL: cfg.PasswordResetTTL,
45
+ PasswordResetURL: cfg.PasswordResetURL,
46
+ EmailVerifyTTL: cfg.EmailVerifyTTL,
47
+ EmailVerifyURL: cfg.EmailVerifyURL,
48
+ })
37
49
  ctx := context.Background()
38
50
 
39
51
  if email := os.Getenv("SEED_ADMIN_EMAIL"); email != "" {
@@ -11,9 +11,19 @@ post:
11
11
  responses:
12
12
  "200":
13
13
  description: ok, refresh_token set as an httpOnly cookie
14
+ headers:
15
+ Cache-Control:
16
+ description: token responses are never cacheable
17
+ schema: { type: string, example: no-store }
18
+ Pragma:
19
+ description: legacy cache prevention for token responses
20
+ schema: { type: string, example: no-cache }
14
21
  content:
15
22
  application/json:
16
- schema: { $ref: './schemas.yaml#/AuthResponse' }
23
+ schema:
24
+ oneOf:
25
+ - { $ref: './schemas.yaml#/AuthResponse' }
26
+ - { $ref: './schemas.yaml#/MFAChallengeResponse' }
17
27
  "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
28
  "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
19
29
  "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Complete a login with a TOTP or recovery code
3
+ operationId: verifyMFA
4
+ tags: [auth]
5
+ security: []
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/MFAChallengeInput' }
11
+ responses:
12
+ "200":
13
+ description: authenticated; refresh_token set as an httpOnly cookie
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
17
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
19
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }