@nakedev/go-scaffold 0.3.3 → 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.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
|
@@ -78,24 +78,40 @@ function patchUserJWTForRbac(jwtGoPath) {
|
|
|
78
78
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:jwt-claims-values", "Role: role,", "Role: role,");
|
|
79
79
|
fs_extra_1.default.writeFileSync(jwtGoPath, content);
|
|
80
80
|
}
|
|
81
|
-
// patchUserServiceForRbac
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
function patchUserServiceForRbac(serviceGoPath) {
|
|
81
|
+
// patchUserServiceForRbac adds the RBAC behavior to user.Service. The base
|
|
82
|
+
// auth constructor already exposes Dependencies.Roles, so enabling RBAC does
|
|
83
|
+
// not mutate a positional constructor signature anymore.
|
|
84
|
+
function patchUserServiceForRbac(serviceGoPath, goModule, sessionsGoPath) {
|
|
86
85
|
let content = fs_extra_1.default.readFileSync(serviceGoPath, "utf8");
|
|
87
86
|
const roleCheckerInterface = [
|
|
88
|
-
"//
|
|
87
|
+
"// RoleChecker is what the service needs from the role domain to validate a",
|
|
89
88
|
"// role assignment — declared consumer-side, same convention as repository/mailer.",
|
|
90
|
-
"type
|
|
89
|
+
"type RoleChecker interface {",
|
|
91
90
|
"\tCodeExists(ctx context.Context, code string) (bool, error)",
|
|
92
91
|
"}",
|
|
93
92
|
].join("\n");
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
if (!content.includes("type RoleChecker interface") && !content.includes("type roleChecker interface")) {
|
|
94
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-interfaces", roleCheckerInterface, "type RoleChecker interface");
|
|
95
|
+
}
|
|
96
|
+
// issueTokens moved out of service.go with auth's use-case split. Keep the
|
|
97
|
+
// patch compatible with both shapes: new projects patch sessions.go, while
|
|
98
|
+
// older generated projects still have the marker in service.go.
|
|
99
|
+
const tokenIssuePath = sessionsGoPath && fs_extra_1.default.existsSync(sessionsGoPath) ? sessionsGoPath : serviceGoPath;
|
|
100
|
+
let tokenIssueContent = tokenIssuePath === serviceGoPath ? content : fs_extra_1.default.readFileSync(tokenIssuePath, "utf8");
|
|
101
|
+
tokenIssueContent = (0, marker_patch_1.insertBeforeMarkerOnce)(tokenIssueContent, "// go-scaffold:issue-access-token-args", "u.Role,", "u.Role,");
|
|
102
|
+
if (tokenIssuePath === serviceGoPath) {
|
|
103
|
+
content = tokenIssueContent;
|
|
104
|
+
}
|
|
105
|
+
// Auth keeps its base service focused and therefore does not need RBAC's
|
|
106
|
+
// error/ORM imports until this optional method is patched in. Add them here
|
|
107
|
+
// instead of carrying unused imports in every auth-only project. The module
|
|
108
|
+
// argument is optional for callers that patch legacy projects whose service
|
|
109
|
+
// already imported apperror; new CLI calls always provide it.
|
|
110
|
+
if (goModule) {
|
|
111
|
+
content = (0, marker_patch_1.ensureImport)(content, "errors");
|
|
112
|
+
content = (0, marker_patch_1.ensureImport)(content, `${goModule}/internal/shared/apperror`);
|
|
113
|
+
content = (0, marker_patch_1.ensureImport)(content, "gorm.io/gorm");
|
|
114
|
+
}
|
|
99
115
|
const setRoleMethod = [
|
|
100
116
|
"// SetRole assigns userID a new role — validated against the role catalog",
|
|
101
117
|
"// (not just the DB's FK) so a typo'd code fails with a clear 422 here",
|
|
@@ -107,33 +123,30 @@ function patchUserServiceForRbac(serviceGoPath) {
|
|
|
107
123
|
"\t\tif errors.Is(err, gorm.ErrRecordNotFound) {",
|
|
108
124
|
"\t\t\treturn nil, errNotFound()",
|
|
109
125
|
"\t\t}",
|
|
110
|
-
"\t\treturn nil, apperror.NewInternal()",
|
|
126
|
+
"\t\treturn nil, apperror.NewInternal(err)",
|
|
111
127
|
"\t}",
|
|
112
128
|
"\texists, err := s.roles.CodeExists(ctx, roleCode)",
|
|
113
129
|
"\tif err != nil {",
|
|
114
|
-
"\t\treturn nil, apperror.NewInternal()",
|
|
130
|
+
"\t\treturn nil, apperror.NewInternal(err)",
|
|
115
131
|
"\t}",
|
|
116
132
|
"\tif !exists {",
|
|
117
133
|
"\t\treturn nil, errUnknownRole()",
|
|
118
134
|
"\t}",
|
|
119
135
|
"\tu.Role = roleCode",
|
|
120
136
|
"\tif err := s.repo.UpdateUser(ctx, u); err != nil {",
|
|
121
|
-
"\t\treturn nil, apperror.NewInternal()",
|
|
137
|
+
"\t\treturn nil, apperror.NewInternal(err)",
|
|
122
138
|
"\t}",
|
|
123
139
|
"\treturn u, nil",
|
|
124
140
|
"}",
|
|
125
141
|
].join("\n");
|
|
126
142
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-methods", setRoleMethod, "func (s *Service) SetRole(");
|
|
127
143
|
fs_extra_1.default.writeFileSync(serviceGoPath, content);
|
|
144
|
+
if (tokenIssuePath !== serviceGoPath)
|
|
145
|
+
fs_extra_1.default.writeFileSync(tokenIssuePath, tokenIssueContent);
|
|
128
146
|
}
|
|
129
|
-
// patchUserServiceTestForRbac
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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.
|
|
147
|
+
// patchUserServiceTestForRbac supplies a fake role capability through the
|
|
148
|
+
// explicit Dependencies literal. The fake itself remains opt-in so auth-only
|
|
149
|
+
// projects do not carry an unused RBAC test double.
|
|
137
150
|
function patchUserServiceTestForRbac(serviceTestGoPath) {
|
|
138
151
|
let content = fs_extra_1.default.readFileSync(serviceTestGoPath, "utf8");
|
|
139
152
|
const fakeRoles = [
|
|
@@ -143,7 +156,7 @@ function patchUserServiceTestForRbac(serviceTestGoPath) {
|
|
|
143
156
|
"func (fakeRoles) CodeExists(context.Context, string) (bool, error) { return true, nil }",
|
|
144
157
|
].join("\n");
|
|
145
158
|
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-
|
|
159
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-deps", "Roles: fakeRoles{},", "Roles: fakeRoles{},");
|
|
147
160
|
fs_extra_1.default.writeFileSync(serviceTestGoPath, content);
|
|
148
161
|
}
|
|
149
162
|
// patchUserDTOForRbac adds the request DTO for PATCH /users/:id/set-role and
|
|
@@ -164,13 +177,26 @@ function patchUserDTOForRbac(dtoGoPath) {
|
|
|
164
177
|
// /permissions for the actual role/permission management API.
|
|
165
178
|
function patchUserHandlerForRbac(handlerGoPath, goModule) {
|
|
166
179
|
let content = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
|
|
180
|
+
// The base auth handler is intentionally only the route/composition surface;
|
|
181
|
+
// its endpoint implementations now live in sibling files. RBAC still
|
|
182
|
+
// patches its admin handlers into handler.go for marker compatibility, so
|
|
183
|
+
// make the imports required by those injected handlers explicit here rather
|
|
184
|
+
// than relying on imports that used to come from the old monolithic file.
|
|
185
|
+
content = (0, marker_patch_1.ensureImport)(content, "net/http");
|
|
186
|
+
content = (0, marker_patch_1.ensureImport)(content, `${goModule}/internal/shared/httpx`);
|
|
167
187
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserManageRole = "user:manage-role"', "PermUserManageRole");
|
|
168
188
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserRead = "user:read"', "PermUserRead");
|
|
169
189
|
const paginationImport = `"${goModule}/internal/shared/pagination"`;
|
|
170
190
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-imports", paginationImport, "internal/shared/pagination");
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
191
|
+
if (!/\bauthz\s+(?:\*middleware\.Authz|authorizer)\b/.test(content)) {
|
|
192
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-fields", "authz *middleware.Authz", "authz *middleware.Authz");
|
|
193
|
+
}
|
|
194
|
+
if (!/\bauthz\s+\.\.\.(?:\*middleware\.Authz|authorizer)\s*,/.test(content) && !/\bauthz\s+\*middleware\.Authz\s*,/.test(content)) {
|
|
195
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-params", "authz *middleware.Authz,", "authz *middleware.Authz,");
|
|
196
|
+
}
|
|
197
|
+
if (!/authz:\s+authzMiddleware,/.test(content) && !/authz:\s+authz,/.test(content)) {
|
|
198
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-init", "authz: authz,", "authz: authz,");
|
|
199
|
+
}
|
|
174
200
|
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
201
|
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
202
|
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"');
|
|
@@ -233,28 +259,24 @@ function patchUserErrorsForRbac(errorsGoPath) {
|
|
|
233
259
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-errors", errFn, "func errUnknownRole(");
|
|
234
260
|
fs_extra_1.default.writeFileSync(errorsGoPath, content);
|
|
235
261
|
}
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
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.
|
|
262
|
+
// userSvcLineFor rebuilds the legacy line `add auth` wrote before auth got a
|
|
263
|
+
// feature-local composition root. It remains here so `add rbac` can upgrade an
|
|
264
|
+
// older generated project without pulling the old construction shape into new
|
|
265
|
+
// output.
|
|
247
266
|
function userSvcLineFor(goModule, store, worker) {
|
|
248
267
|
const { tokenStore, mailer } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
|
|
249
|
-
return `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
|
|
268
|
+
return `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg, application.NewProviderRegistry())`;
|
|
250
269
|
}
|
|
251
270
|
function assertRbacPatchable(mainGoPath, goModule, store, worker) {
|
|
252
|
-
const
|
|
253
|
-
|
|
271
|
+
const wiring = { goModule, queueBackend: "river", store, worker };
|
|
272
|
+
const content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
273
|
+
const expected = (0, auth_patcher_1.authHandlerLineFor)(wiring);
|
|
274
|
+
const legacyRoute = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${(0, auth_patcher_1.authWiringLines)(wiring).limiter}).Register(api)`;
|
|
275
|
+
if (content.includes(expected) || content.includes(userSvcLineFor(goModule, store, worker)) || content.includes(legacyRoute))
|
|
254
276
|
return;
|
|
255
|
-
throw new Error("cmd/api/wiring.go's
|
|
277
|
+
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
278
|
`Expected to find:\n ${expected}\n\n` +
|
|
257
|
-
"It was probably hand-edited. Restore that
|
|
279
|
+
"It was probably hand-edited. Restore that route and re-run — nothing has been changed yet.");
|
|
258
280
|
}
|
|
259
281
|
function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
|
|
260
282
|
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
@@ -272,37 +294,36 @@ function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
|
|
|
272
294
|
for (const line of migrateLines) {
|
|
273
295
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
|
|
274
296
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
297
|
+
const wiring = { goModule, queueBackend: "river", store, worker };
|
|
298
|
+
const authRouteLine = (0, auth_patcher_1.authHandlerLineFor)(wiring);
|
|
299
|
+
const roleCompositionLine = "roleComposition := role.NewCompositionFromDB(db, cfg.JWTSecret, cfg.AuthzCacheTTL)";
|
|
300
|
+
const roleHandlerLine = "roleComposition.Handler.Register(api)";
|
|
301
|
+
if (content.includes(authRouteLine)) {
|
|
302
|
+
const authWithRoleLine = (0, auth_patcher_1.authHandlerLineFor)(wiring, ["roleComposition.Service", "roleComposition.Authz"]);
|
|
303
|
+
content = content.replace(authRouteLine, [roleCompositionLine, authWithRoleLine, roleHandlerLine].join("\n"));
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
// Upgrade projects generated before auth's local composition root. The
|
|
307
|
+
// role feature is still constructed locally; only the legacy user service
|
|
308
|
+
// call needs an adapter until that project is regenerated.
|
|
309
|
+
const applicationImportLine = `"${goModule}/internal/app/user/application"`;
|
|
310
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, applicationImportLine, applicationImportLine);
|
|
311
|
+
const userSvcLine = userSvcLineFor(goModule, store, worker);
|
|
312
|
+
if (!content.includes(userSvcLine)) {
|
|
313
|
+
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` +
|
|
314
|
+
`Expected to find:\n ${authRouteLine}\n\n` +
|
|
315
|
+
"It was probably hand-edited. Restore that route and re-run — nothing has been changed yet.");
|
|
316
|
+
}
|
|
317
|
+
content = content.replace(userSvcLine, [roleCompositionLine, `${userSvcLine.slice(0, -1)}, roleComposition.Service)`].join("\n"));
|
|
318
|
+
const limiter = (0, auth_patcher_1.authWiringLines)(wiring).limiter;
|
|
319
|
+
const legacyRoute = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
|
|
320
|
+
const tail = ").Register(api)";
|
|
321
|
+
if (!content.includes(legacyRoute)) {
|
|
322
|
+
throw new Error("cmd/api/wiring.go is missing auth's legacy handler route while adding RBAC");
|
|
323
|
+
}
|
|
324
|
+
content = content.replace(legacyRoute, `${legacyRoute.slice(0, -tail.length)}, roleComposition.Authz${tail}`);
|
|
325
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, roleHandlerLine, roleHandlerLine);
|
|
293
326
|
}
|
|
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);
|
|
306
327
|
fs_extra_1.default.writeFileSync(mainGoPath, content);
|
|
307
328
|
}
|
|
308
329
|
// patchCmdSeedForRbac makes the seeded admin actually an admin: wires a
|
|
@@ -313,9 +334,10 @@ function patchCmdSeedForRbac(seedMainGoPath, goModule) {
|
|
|
313
334
|
let content = fs_extra_1.default.readFileSync(seedMainGoPath, "utf8");
|
|
314
335
|
const importLine = `"${goModule}/internal/app/role"`;
|
|
315
336
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-imports", importLine, importLine);
|
|
316
|
-
const roleSvcLine = "roleSvc := role.
|
|
337
|
+
const roleSvcLine = "roleSvc := role.NewServiceFromDB(db)";
|
|
317
338
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-services", roleSvcLine, roleSvcLine);
|
|
318
|
-
|
|
339
|
+
const seedRoleDependency = content.includes("user.Dependencies{") ? "Roles: roleSvc," : "roleSvc,";
|
|
340
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-user-service-args", seedRoleDependency, seedRoleDependency);
|
|
319
341
|
const setRoleCall = [
|
|
320
342
|
'if _, err := svc.SetRole(ctx, u.ID, "admin"); err != nil {',
|
|
321
343
|
'\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.
|
|
3
|
+
"version": "0.4.3",
|
|
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",
|
|
@@ -30,10 +30,20 @@ func main() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// go-scaffold:seed-services
|
|
33
|
-
svc := user.NewService(
|
|
34
|
-
user.NewRepository(db),
|
|
33
|
+
svc := user.NewService(user.Dependencies{
|
|
34
|
+
Repository: user.NewRepository(db),
|
|
35
35
|
// go-scaffold:seed-user-service-args
|
|
36
|
-
|
|
36
|
+
}, user.AuthConfig{
|
|
37
|
+
JWTSecret: cfg.JWTSecret,
|
|
38
|
+
JWTAccessTTL: cfg.JWTAccessTTL,
|
|
39
|
+
JWTRefreshTTL: cfg.JWTRefreshTTL,
|
|
40
|
+
JWTRefreshMaxTTL: cfg.JWTRefreshMaxTTL,
|
|
41
|
+
OAuthStateTTL: cfg.OAuthStateTTL,
|
|
42
|
+
PasswordResetTTL: cfg.PasswordResetTTL,
|
|
43
|
+
PasswordResetURL: cfg.PasswordResetURL,
|
|
44
|
+
EmailVerifyTTL: cfg.EmailVerifyTTL,
|
|
45
|
+
EmailVerifyURL: cfg.EmailVerifyURL,
|
|
46
|
+
})
|
|
37
47
|
ctx := context.Background()
|
|
38
48
|
|
|
39
49
|
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:
|
|
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' }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Exchange a provider authorization code for a local session
|
|
3
|
+
description: The frontend callback route sends the code, the same state, and the PKCE verifier to the API. The API consumes a one-time server-side state transaction bound to the provider, S256 challenge, and OIDC nonce, uses its exact provider-registered redirect URI, validates the provider response, resolves the local identity, sets an HttpOnly refresh cookie, and returns an access token. The request has no redirect_uri, return_to, or frontend destination; credentials are never placed in a URI.
|
|
4
|
+
operationId: providerExchange
|
|
5
|
+
tags: [auth]
|
|
6
|
+
security: []
|
|
7
|
+
parameters:
|
|
8
|
+
- name: provider
|
|
9
|
+
in: path
|
|
10
|
+
required: true
|
|
11
|
+
schema: { type: string, example: google }
|
|
12
|
+
requestBody:
|
|
13
|
+
required: true
|
|
14
|
+
content:
|
|
15
|
+
application/json:
|
|
16
|
+
schema:
|
|
17
|
+
$ref: "./schemas.yaml#/OAuthExchangeInput"
|
|
18
|
+
responses:
|
|
19
|
+
"200":
|
|
20
|
+
description: local session created; refresh token is set only as an HttpOnly cookie
|
|
21
|
+
headers:
|
|
22
|
+
Set-Cookie:
|
|
23
|
+
description: HttpOnly refresh cookie
|
|
24
|
+
schema: { type: string }
|
|
25
|
+
Cache-Control:
|
|
26
|
+
description: token responses are never cacheable
|
|
27
|
+
schema: { type: string, example: no-store }
|
|
28
|
+
Pragma:
|
|
29
|
+
description: legacy cache prevention for token responses
|
|
30
|
+
schema: { type: string, example: no-cache }
|
|
31
|
+
content:
|
|
32
|
+
application/json:
|
|
33
|
+
schema:
|
|
34
|
+
oneOf:
|
|
35
|
+
- { $ref: "./schemas.yaml#/AuthResponse" }
|
|
36
|
+
- { $ref: "./schemas.yaml#/MFAChallengeResponse" }
|
|
37
|
+
"400":
|
|
38
|
+
description: controlled OAuth error (oauth_denied, oauth_state_invalid, or oauth_failed)
|
|
39
|
+
"503":
|
|
40
|
+
description: provider is unavailable or not configured
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: Start an external OAuth login flow
|
|
3
|
+
description: Redirects the browser to the configured provider. The browser client supplies state and an S256 PKCE challenge; the server creates a one-time transaction bound to that state, provider, challenge, and OIDC nonce, then uses the exact provider-registered redirect URI. It never accepts a client redirect URI.
|
|
4
|
+
operationId: providerLogin
|
|
5
|
+
tags: [auth]
|
|
6
|
+
security: []
|
|
7
|
+
parameters:
|
|
8
|
+
- name: provider
|
|
9
|
+
in: path
|
|
10
|
+
required: true
|
|
11
|
+
schema: { type: string, example: google }
|
|
12
|
+
- name: state
|
|
13
|
+
in: query
|
|
14
|
+
required: true
|
|
15
|
+
schema: { type: string, minLength: 1 }
|
|
16
|
+
- name: code_challenge
|
|
17
|
+
in: query
|
|
18
|
+
required: true
|
|
19
|
+
schema:
|
|
20
|
+
type: string
|
|
21
|
+
minLength: 43
|
|
22
|
+
maxLength: 128
|
|
23
|
+
pattern: '^[A-Za-z0-9._~-]+$'
|
|
24
|
+
- name: code_challenge_method
|
|
25
|
+
in: query
|
|
26
|
+
required: true
|
|
27
|
+
schema: { type: string, enum: [S256] }
|
|
28
|
+
responses:
|
|
29
|
+
"302": { description: redirect to the provider's authorization endpoint }
|
|
30
|
+
"400": { description: missing or invalid state/PKCE parameters; response uses a controlled OAuth error code }
|
|
31
|
+
"503": { description: provider is unavailable or not configured }
|
|
@@ -9,6 +9,13 @@ post:
|
|
|
9
9
|
responses:
|
|
10
10
|
"200":
|
|
11
11
|
description: ok, a new refresh_token cookie is set
|
|
12
|
+
headers:
|
|
13
|
+
Cache-Control:
|
|
14
|
+
description: token responses are never cacheable
|
|
15
|
+
schema: { type: string, example: no-store }
|
|
16
|
+
Pragma:
|
|
17
|
+
description: legacy cache prevention for token responses
|
|
18
|
+
schema: { type: string, example: no-cache }
|
|
12
19
|
content:
|
|
13
20
|
application/json:
|
|
14
21
|
schema: { $ref: './schemas.yaml#/AuthResponse' }
|
|
@@ -11,6 +11,13 @@ post:
|
|
|
11
11
|
responses:
|
|
12
12
|
"201":
|
|
13
13
|
description: created, 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
23
|
schema: { $ref: './schemas.yaml#/AuthResponse' }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
post:
|
|
2
2
|
summary: Reset a password using a reset token
|
|
3
|
-
description: The token is
|
|
3
|
+
description: The token is consumed only after the password update and refresh-session revocation complete. If the session store is temporarily unavailable, the internal error response restores the token so the caller can retry the complete operation.
|
|
4
4
|
operationId: resetPassword
|
|
5
5
|
tags: [auth]
|
|
6
6
|
security: []
|
|
@@ -32,8 +32,66 @@ VerifyEmailInput:
|
|
|
32
32
|
properties:
|
|
33
33
|
token: { type: string }
|
|
34
34
|
|
|
35
|
+
MFAChallengeInput:
|
|
36
|
+
type: object
|
|
37
|
+
required: [challenge, code]
|
|
38
|
+
additionalProperties: false
|
|
39
|
+
properties:
|
|
40
|
+
challenge: { type: string, minLength: 1 }
|
|
41
|
+
code: { type: string, minLength: 6, maxLength: 16 }
|
|
42
|
+
|
|
43
|
+
MFACodeInput:
|
|
44
|
+
type: object
|
|
45
|
+
required: [code]
|
|
46
|
+
additionalProperties: false
|
|
47
|
+
properties:
|
|
48
|
+
code: { type: string, minLength: 6, maxLength: 16 }
|
|
49
|
+
|
|
50
|
+
MFAChallengeResponse:
|
|
51
|
+
type: object
|
|
52
|
+
required: [mfa_required, challenge]
|
|
53
|
+
properties:
|
|
54
|
+
mfa_required: { type: boolean, example: true }
|
|
55
|
+
challenge: { type: string }
|
|
56
|
+
|
|
57
|
+
MFAStatus:
|
|
58
|
+
type: object
|
|
59
|
+
required: [available, enabled]
|
|
60
|
+
properties:
|
|
61
|
+
available: { type: boolean }
|
|
62
|
+
enabled: { type: boolean }
|
|
63
|
+
|
|
64
|
+
MFASetupResponse:
|
|
65
|
+
type: object
|
|
66
|
+
required: [secret, otpauth_uri]
|
|
67
|
+
properties:
|
|
68
|
+
secret: { type: string, description: show once and add to an authenticator app }
|
|
69
|
+
otpauth_uri: { type: string, format: uri }
|
|
70
|
+
|
|
71
|
+
MFAConfirmResponse:
|
|
72
|
+
type: object
|
|
73
|
+
required: [recovery_codes]
|
|
74
|
+
properties:
|
|
75
|
+
recovery_codes:
|
|
76
|
+
type: array
|
|
77
|
+
items: { type: string }
|
|
78
|
+
|
|
79
|
+
OAuthExchangeInput:
|
|
80
|
+
type: object
|
|
81
|
+
required: [code, state, code_verifier]
|
|
82
|
+
additionalProperties: false
|
|
83
|
+
properties:
|
|
84
|
+
code: { type: string, minLength: 1 }
|
|
85
|
+
state: { type: string, minLength: 1 }
|
|
86
|
+
code_verifier:
|
|
87
|
+
type: string
|
|
88
|
+
minLength: 43
|
|
89
|
+
maxLength: 128
|
|
90
|
+
pattern: '^[A-Za-z0-9._~-]+$'
|
|
91
|
+
|
|
35
92
|
# access_token in the body; refresh_token is set as an httpOnly cookie, never
|
|
36
|
-
# echoed back — see toCookieResponse in internal/app/user/dto.go.
|
|
93
|
+
# echoed back — see toCookieResponse in internal/app/user/dto.go. All token
|
|
94
|
+
# responses carry Cache-Control: no-store and Pragma: no-cache.
|
|
37
95
|
AuthResponse:
|
|
38
96
|
type: object
|
|
39
97
|
properties:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Confirm MFA enrollment with a current TOTP code
|
|
3
|
+
operationId: confirmMyMFA
|
|
4
|
+
tags: [users]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
requestBody:
|
|
7
|
+
required: true
|
|
8
|
+
content:
|
|
9
|
+
application/json:
|
|
10
|
+
schema: { $ref: './schemas.yaml#/MFACodeInput' }
|
|
11
|
+
responses:
|
|
12
|
+
"200":
|
|
13
|
+
description: MFA enabled; recovery codes are returned once
|
|
14
|
+
content:
|
|
15
|
+
application/json:
|
|
16
|
+
schema: { $ref: './schemas.yaml#/MFAConfirmResponse' }
|
|
17
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
18
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
19
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Disable MFA for the current user
|
|
3
|
+
operationId: disableMyMFA
|
|
4
|
+
tags: [users]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
requestBody:
|
|
7
|
+
required: true
|
|
8
|
+
content:
|
|
9
|
+
application/json:
|
|
10
|
+
schema: { $ref: './schemas.yaml#/MFACodeInput' }
|
|
11
|
+
responses:
|
|
12
|
+
"204": { description: MFA disabled }
|
|
13
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
14
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
15
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Start MFA enrollment for the current user
|
|
3
|
+
operationId: setupMyMFA
|
|
4
|
+
tags: [users]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
responses:
|
|
7
|
+
"200":
|
|
8
|
+
description: secret and otpauth URI; enrollment remains disabled until confirmed
|
|
9
|
+
content:
|
|
10
|
+
application/json:
|
|
11
|
+
schema: { $ref: './schemas.yaml#/MFASetupResponse' }
|
|
12
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
13
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|
|
14
|
+
"503": { description: MFA is disabled by the operator }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: Get the current user's MFA status
|
|
3
|
+
operationId: getMyMFAStatus
|
|
4
|
+
tags: [users]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
responses:
|
|
7
|
+
"200":
|
|
8
|
+
description: MFA availability and enrollment status
|
|
9
|
+
content:
|
|
10
|
+
application/json:
|
|
11
|
+
schema: { $ref: './schemas.yaml#/MFAStatus' }
|
|
12
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|