@nakedev/go-scaffold 0.1.4 → 0.3.1
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/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +366 -63
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +71 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +21 -7
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler_test.go.hbs +8 -1
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/model/model.go.hbs +10 -1
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
- package/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- package/tests/unit/smoke-isolation.test.mjs +0 -35
|
@@ -3,7 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.patchComposeForRedis = patchComposeForRedis;
|
|
7
|
+
exports.patchCiForRedis = patchCiForRedis;
|
|
8
|
+
exports.patchConfigForRedis = patchConfigForRedis;
|
|
6
9
|
exports.patchConfigForWorker = patchConfigForWorker;
|
|
10
|
+
exports.patchConfigForSMTP = patchConfigForSMTP;
|
|
7
11
|
exports.patchMainGoForWorker = patchMainGoForWorker;
|
|
8
12
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
13
|
const marker_patch_1 = require("./marker-patch");
|
|
@@ -13,25 +17,107 @@ const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
|
|
|
13
17
|
const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
|
|
14
18
|
const READYZ_MARKER = "// go-scaffold:readyz-checks";
|
|
15
19
|
const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// patchComposeForRedis adds a redis service to docker-compose.yml. Whatever
|
|
21
|
+
// pulls Redis in — `add worker --queue redis`, or `add auth`'s refresh-token
|
|
22
|
+
// store on top of a Postgres queue — makes cmd/api call cache.Open and adds a
|
|
23
|
+
// Redis ping to /readyz. Leaving compose Postgres-only meant the documented
|
|
24
|
+
// path (`make docker-up` then `make run`) produced a permanent 503, which is
|
|
25
|
+
// a rough first five minutes with a brand new project.
|
|
26
|
+
//
|
|
27
|
+
// No-op when the project was scaffolded with --no-docker.
|
|
28
|
+
function patchComposeForRedis(composePath) {
|
|
29
|
+
if (!fs_extra_1.default.existsSync(composePath))
|
|
30
|
+
return;
|
|
31
|
+
const content = fs_extra_1.default.readFileSync(composePath, "utf8");
|
|
32
|
+
if (/^\s{2}redis:/m.test(content))
|
|
33
|
+
return;
|
|
34
|
+
const service = [
|
|
35
|
+
" redis:",
|
|
36
|
+
" image: redis:7-alpine",
|
|
37
|
+
" ports:",
|
|
38
|
+
' - "6379:6379"',
|
|
39
|
+
].join("\n");
|
|
40
|
+
// before the top-level `volumes:` key, so redis stays inside `services:`
|
|
41
|
+
const out = content.includes("\nvolumes:")
|
|
42
|
+
? content.replace("\nvolumes:", () => `\n${service}\n\nvolumes:`)
|
|
43
|
+
: content.replace(/\n?$/, "\n") + `\n${service}\n`;
|
|
44
|
+
fs_extra_1.default.writeFileSync(composePath, out);
|
|
45
|
+
}
|
|
46
|
+
// patchCiForRedis adds a redis service to .github/workflows/ci.yml, for the
|
|
47
|
+
// same reason patchComposeForRedis exists: once cmd/api calls cache.Open, a
|
|
48
|
+
// test that touches the token store has nothing to connect to on a runner
|
|
49
|
+
// whose only service is Postgres. The generated workflow was written at
|
|
50
|
+
// `create` time, before anything needed Redis, and nothing patched it after.
|
|
51
|
+
//
|
|
52
|
+
// No-op when the workflow was deleted or replaced by hand.
|
|
53
|
+
function patchCiForRedis(ciPath) {
|
|
54
|
+
if (!fs_extra_1.default.existsSync(ciPath))
|
|
55
|
+
return;
|
|
56
|
+
const content = fs_extra_1.default.readFileSync(ciPath, "utf8");
|
|
57
|
+
if (/^\s{6}redis:/m.test(content))
|
|
58
|
+
return;
|
|
59
|
+
// `steps:` sits one level under the job, so it's the first line that ends
|
|
60
|
+
// the `services:` block — insert the service just above it.
|
|
61
|
+
const stepsLine = content.split("\n").find((l) => l.trimEnd() === " steps:");
|
|
62
|
+
if (!stepsLine)
|
|
63
|
+
return;
|
|
64
|
+
const service = [
|
|
65
|
+
" redis:",
|
|
66
|
+
" image: redis:7-alpine",
|
|
67
|
+
" ports:",
|
|
68
|
+
" - 6379:6379",
|
|
69
|
+
" options: >-",
|
|
70
|
+
' --health-cmd "redis-cli ping"',
|
|
71
|
+
" --health-interval 10s",
|
|
72
|
+
" --health-timeout 5s",
|
|
73
|
+
" --health-retries 5",
|
|
74
|
+
"",
|
|
75
|
+
].join("\n");
|
|
76
|
+
fs_extra_1.default.writeFileSync(ciPath, content.replace(stepsLine, () => `${service}\n${stepsLine}`));
|
|
77
|
+
}
|
|
78
|
+
// patchConfigForRedis adds RedisURL to Config and its env() load to Load().
|
|
79
|
+
// Split out from the worker patch because Redis is no longer the worker's
|
|
80
|
+
// concern by default — `add auth` needs it for the refresh-token store even
|
|
81
|
+
// when the queue lives in Postgres.
|
|
82
|
+
function patchConfigForRedis(configGoPath) {
|
|
22
83
|
let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
|
|
23
|
-
|
|
24
|
-
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content,
|
|
84
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, "RedisURL string", "RedisURL");
|
|
85
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, 'RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),', 'env("REDIS_URL"');
|
|
86
|
+
fs_extra_1.default.writeFileSync(configGoPath, content);
|
|
87
|
+
}
|
|
88
|
+
// patchConfigForWorker adds the SMTP_* fields (and RedisURL, when Redis is
|
|
89
|
+
// the queue's backing store) to Config and their env() loads to Load() — via
|
|
90
|
+
// marker comments, the same text-insertion approach as patchMainGo, since
|
|
91
|
+
// config.go.hbs is only rendered once (at `create`) and everything after
|
|
92
|
+
// that is a real file a human may have already edited.
|
|
93
|
+
function patchConfigForWorker(configGoPath, opts) {
|
|
94
|
+
if (opts.redis)
|
|
95
|
+
patchConfigForRedis(configGoPath);
|
|
96
|
+
patchConfigForSMTP(configGoPath);
|
|
97
|
+
}
|
|
98
|
+
// Sentinels here match a bare identifier, never `Name string` or
|
|
99
|
+
// `Name: env(...)`: gofmt aligns struct fields and map values into columns, so
|
|
100
|
+
// the moment one of these blocks lands the literal spacing a sentinel was
|
|
101
|
+
// written with no longer exists in the file. A sentinel that misses means the
|
|
102
|
+
// block gets inserted a second time and the project stops compiling on a
|
|
103
|
+
// redeclared field — which is exactly what `add auth` then `add worker` did.
|
|
104
|
+
//
|
|
105
|
+
// patchConfigForSMTP is split out because `add auth` needs these fields even
|
|
106
|
+
// when there is no worker: without a queue it sends mail synchronously, and it
|
|
107
|
+
// still has to know where to send it. Idempotent, so whichever command gets
|
|
108
|
+
// here first wins and the second is a no-op.
|
|
109
|
+
function patchConfigForSMTP(configGoPath) {
|
|
110
|
+
let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
|
|
111
|
+
const fieldsBlock = ["SMTPHost string", "SMTPPort string", "SMTPUsername string", "SMTPPassword string", "SMTPFrom string"].join("\n");
|
|
112
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "SMTPHost");
|
|
25
113
|
const loadBlock = [
|
|
26
|
-
'RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),',
|
|
27
|
-
"",
|
|
28
114
|
'SMTPHost: env("SMTP_HOST", ""),',
|
|
29
115
|
'SMTPPort: env("SMTP_PORT", "587"),',
|
|
30
116
|
'SMTPUsername: env("SMTP_USERNAME", ""),',
|
|
31
117
|
'SMTPPassword: env("SMTP_PASSWORD", ""),',
|
|
32
118
|
'SMTPFrom: env("SMTP_FROM", "no-reply@example.local"),',
|
|
33
119
|
].join("\n");
|
|
34
|
-
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, '
|
|
120
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'env("SMTP_HOST"');
|
|
35
121
|
fs_extra_1.default.writeFileSync(configGoPath, content);
|
|
36
122
|
}
|
|
37
123
|
// patchMainGoForWorker wires Redis into cmd/api: opened alongside the DB, and
|
|
@@ -44,7 +130,7 @@ function patchMainGoForWorker(mainGoPath, goModule) {
|
|
|
44
130
|
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
45
131
|
const cacheImport = `"${goModule}/internal/platform/cache"`;
|
|
46
132
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, cacheImport, cacheImport);
|
|
47
|
-
const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\
|
|
133
|
+
const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\treturn fmt.Errorf("open redis: %w", err)', "}"].join("\n");
|
|
48
134
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, initBlock, "rdb, err := cache.Open(cfg)");
|
|
49
135
|
const readyzBlock = [
|
|
50
136
|
"if err := rdb.Ping(c.Request.Context()).Err(); err != nil {",
|
|
@@ -13,12 +13,17 @@ exports.patchUserServiceTestForRbac = patchUserServiceTestForRbac;
|
|
|
13
13
|
exports.patchUserDTOForRbac = patchUserDTOForRbac;
|
|
14
14
|
exports.patchUserHandlerForRbac = patchUserHandlerForRbac;
|
|
15
15
|
exports.patchUserErrorsForRbac = patchUserErrorsForRbac;
|
|
16
|
+
exports.userSvcLineFor = userSvcLineFor;
|
|
17
|
+
exports.assertRbacPatchable = assertRbacPatchable;
|
|
16
18
|
exports.patchMainGoForRbac = patchMainGoForRbac;
|
|
17
19
|
exports.patchCmdSeedForRbac = patchCmdSeedForRbac;
|
|
18
20
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
21
|
+
const auth_patcher_1 = require("./auth-patcher");
|
|
19
22
|
const marker_patch_1 = require("./marker-patch");
|
|
20
23
|
const IMPORT_MARKER = "// go-scaffold:imports";
|
|
24
|
+
const SCHEMA_MARKER = "// go-scaffold:schemas";
|
|
21
25
|
const MODEL_MARKER = "// go-scaffold:models";
|
|
26
|
+
const ROUTE_MARKER = "// go-scaffold:routes";
|
|
22
27
|
const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
|
|
23
28
|
const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
|
|
24
29
|
// patchAuthDocsForRbac adds `role` to the hand-written MeResponse schema in
|
|
@@ -233,26 +238,71 @@ function patchUserErrorsForRbac(errorsGoPath) {
|
|
|
233
238
|
// isn't enough — REPLACES the `add auth` PR's user.NewHandler(...) call with
|
|
234
239
|
// a version that also builds roleSvc/authz and passes them through, plus
|
|
235
240
|
// registers role's own routes right after it.
|
|
236
|
-
|
|
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)`;
|
|
250
|
+
}
|
|
251
|
+
function assertRbacPatchable(mainGoPath, goModule, store, worker) {
|
|
252
|
+
const expected = userSvcLineFor(goModule, store, worker);
|
|
253
|
+
if (fs_extra_1.default.readFileSync(mainGoPath, "utf8").includes(expected))
|
|
254
|
+
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" +
|
|
256
|
+
`Expected to find:\n ${expected}\n\n` +
|
|
257
|
+
"It was probably hand-edited. Restore that line and re-run — nothing has been changed yet.");
|
|
258
|
+
}
|
|
259
|
+
function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
|
|
237
260
|
let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
|
|
238
261
|
const importLine = `"${goModule}/internal/app/role"`;
|
|
239
262
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
|
|
240
263
|
const modelImportLine = `rolemodel "${goModule}/internal/app/role/model"`;
|
|
241
264
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
|
|
265
|
+
const schemaBlock = [
|
|
266
|
+
'if err := db.Exec("CREATE SCHEMA IF NOT EXISTS role_svc").Error; err != nil {',
|
|
267
|
+
'\treturn fmt.Errorf("create schema role_svc: %w", err)',
|
|
268
|
+
"}",
|
|
269
|
+
].join("\n");
|
|
270
|
+
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS role_svc");
|
|
242
271
|
const migrateLines = ["&rolemodel.Role{},", "&rolemodel.Permission{},", "&rolemodel.RolePermission{},"];
|
|
243
272
|
for (const line of migrateLines) {
|
|
244
273
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
|
|
245
274
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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.`);
|
|
255
293
|
}
|
|
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);
|
|
256
306
|
fs_extra_1.default.writeFileSync(mainGoPath, content);
|
|
257
307
|
}
|
|
258
308
|
// patchCmdSeedForRbac makes the seeded admin actually an admin: wires a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nakedev/go-scaffold",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,9 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
14
14
|
"templates",
|
|
15
|
-
"bin"
|
|
16
|
-
"scripts",
|
|
17
|
-
"tests"
|
|
15
|
+
"bin"
|
|
18
16
|
],
|
|
19
17
|
"type": "commonjs",
|
|
20
18
|
"packageManager": "pnpm@11.0.7",
|
|
@@ -26,7 +24,7 @@
|
|
|
26
24
|
"test:integration": "node --test tests/integration/*.test.mjs",
|
|
27
25
|
"test:smoke": "node scripts/smoke-test.mjs",
|
|
28
26
|
"verify": "pnpm run build && pnpm run test",
|
|
29
|
-
"prepack": "pnpm run build",
|
|
27
|
+
"prepack": "rm -rf dist && pnpm run build",
|
|
30
28
|
"prepublishOnly": "pnpm run verify"
|
|
31
29
|
},
|
|
32
30
|
"keywords": [
|
|
@@ -21,6 +21,13 @@ func errEmailTaken() *apperror.AppError {
|
|
|
21
21
|
// errInvalidCredentials is returned for both "no such user" and "wrong
|
|
22
22
|
// password" — a single generic message so a login attempt can't be used to
|
|
23
23
|
// enumerate which emails have accounts.
|
|
24
|
+
// errTooManyAttempts is the account-level lockout, distinct from the per-IP
|
|
25
|
+
// RATE_LIMITED the middleware returns: this one follows the account wherever
|
|
26
|
+
// the attempts come from.
|
|
27
|
+
func errTooManyAttempts() *apperror.AppError {
|
|
28
|
+
return apperror.New(http.StatusTooManyRequests, "AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts — try again later")
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
func errInvalidCredentials() *apperror.AppError {
|
|
25
32
|
return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
|
|
26
33
|
}
|
|
@@ -2,6 +2,7 @@ package user
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
|
+
"strings"
|
|
5
6
|
"time"
|
|
6
7
|
|
|
7
8
|
"{{goModule}}/internal/shared/httpx"
|
|
@@ -10,19 +11,26 @@ import (
|
|
|
10
11
|
|
|
11
12
|
"github.com/gin-gonic/gin"
|
|
12
13
|
"github.com/google/uuid"
|
|
13
|
-
"github.com/redis/go-redis/v9"
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const (
|
|
17
|
+
refreshCookieName = "refresh_token"
|
|
18
|
+
// oauthStateCookieName holds the nonce that binds a Google login to the
|
|
19
|
+
// browser that started it — see Service.GoogleLoginURL.
|
|
20
|
+
oauthStateCookieName = "oauth_state"
|
|
21
|
+
)
|
|
17
22
|
|
|
18
23
|
// go-scaffold:user-handler-consts
|
|
19
24
|
|
|
20
25
|
type Handler struct {
|
|
21
|
-
svc
|
|
22
|
-
jwtSecret
|
|
23
|
-
refreshTTL
|
|
24
|
-
cookieSecure
|
|
25
|
-
|
|
26
|
+
svc *Service
|
|
27
|
+
jwtSecret string
|
|
28
|
+
refreshTTL time.Duration
|
|
29
|
+
cookieSecure bool
|
|
30
|
+
cookieSameSite string
|
|
31
|
+
// limiter, not a *redis.Client: which backing store counts the requests is
|
|
32
|
+
// decided by `add auth --store`, and this file must not care.
|
|
33
|
+
limiter middleware.Limiter
|
|
26
34
|
// go-scaffold:user-handler-fields
|
|
27
35
|
}
|
|
28
36
|
|
|
@@ -31,15 +39,17 @@ func NewHandler(
|
|
|
31
39
|
jwtSecret string,
|
|
32
40
|
refreshTTL time.Duration,
|
|
33
41
|
cookieSecure bool,
|
|
34
|
-
|
|
42
|
+
cookieSameSite string,
|
|
43
|
+
limiter middleware.Limiter,
|
|
35
44
|
// go-scaffold:user-handler-params
|
|
36
45
|
) *Handler {
|
|
37
46
|
return &Handler{
|
|
38
|
-
svc:
|
|
39
|
-
jwtSecret:
|
|
40
|
-
refreshTTL:
|
|
41
|
-
cookieSecure:
|
|
42
|
-
|
|
47
|
+
svc: svc,
|
|
48
|
+
jwtSecret: jwtSecret,
|
|
49
|
+
refreshTTL: refreshTTL,
|
|
50
|
+
cookieSecure: cookieSecure,
|
|
51
|
+
cookieSameSite: cookieSameSite,
|
|
52
|
+
limiter: limiter,
|
|
43
53
|
// go-scaffold:user-handler-init
|
|
44
54
|
}
|
|
45
55
|
}
|
|
@@ -52,12 +62,12 @@ func (h *Handler) Register(rg gin.IRouter) {
|
|
|
52
62
|
// doesn't spend another's budget. refresh/logout/google aren't limited:
|
|
53
63
|
// refresh/logout are gated by possessing a valid cookie already, and the
|
|
54
64
|
// Google flow's abuse surface lives on Google's side, not ours.
|
|
55
|
-
loginLimit := middleware.RateLimit(h.
|
|
56
|
-
registerLimit := middleware.RateLimit(h.
|
|
57
|
-
forgotPasswordLimit := middleware.RateLimit(h.
|
|
58
|
-
resetPasswordLimit := middleware.RateLimit(h.
|
|
59
|
-
verifyEmailLimit := middleware.RateLimit(h.
|
|
60
|
-
resendVerificationLimit := middleware.RateLimit(h.
|
|
65
|
+
loginLimit := middleware.RateLimit(h.limiter, "login", 10, time.Minute)
|
|
66
|
+
registerLimit := middleware.RateLimit(h.limiter, "register", 5, time.Minute)
|
|
67
|
+
forgotPasswordLimit := middleware.RateLimit(h.limiter, "forgot-password", 5, time.Minute)
|
|
68
|
+
resetPasswordLimit := middleware.RateLimit(h.limiter, "reset-password", 10, time.Minute)
|
|
69
|
+
verifyEmailLimit := middleware.RateLimit(h.limiter, "verify-email", 10, time.Minute)
|
|
70
|
+
resendVerificationLimit := middleware.RateLimit(h.limiter, "resend-verification", 5, time.Minute)
|
|
61
71
|
|
|
62
72
|
authGroup := rg.Group("/auth")
|
|
63
73
|
authGroup.POST("/register", registerLimit, h.register)
|
|
@@ -194,16 +204,26 @@ func (h *Handler) logoutAll(c *gin.Context) {
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
func (h *Handler) googleLogin(c *gin.Context) {
|
|
197
|
-
url, err := h.svc.GoogleLoginURL()
|
|
207
|
+
url, nonce, err := h.svc.GoogleLoginURL()
|
|
198
208
|
if err != nil {
|
|
199
209
|
c.Error(err)
|
|
200
210
|
return
|
|
201
211
|
}
|
|
212
|
+
// Lax, not Strict, and deliberately not h.cookieSameSite: the callback
|
|
213
|
+
// arrives as a top-level navigation from Google, i.e. cross-site, and a
|
|
214
|
+
// Strict cookie is not sent on one — the flow would fail every time.
|
|
215
|
+
c.SetSameSite(http.SameSiteLaxMode)
|
|
216
|
+
c.SetCookie(oauthStateCookieName, nonce, int(oauthStateTTL.Seconds()), "/", "", h.cookieSecure, true)
|
|
202
217
|
c.Redirect(http.StatusFound, url)
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
func (h *Handler) googleCallback(c *gin.Context) {
|
|
206
|
-
|
|
221
|
+
nonce, _ := c.Cookie(oauthStateCookieName)
|
|
222
|
+
// One shot, whatever happens next.
|
|
223
|
+
c.SetSameSite(http.SameSiteLaxMode)
|
|
224
|
+
c.SetCookie(oauthStateCookieName, "", -1, "/", "", h.cookieSecure, true)
|
|
225
|
+
|
|
226
|
+
auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"), nonce)
|
|
207
227
|
if err != nil {
|
|
208
228
|
c.Error(err)
|
|
209
229
|
return
|
|
@@ -225,11 +245,32 @@ func (h *Handler) me(c *gin.Context) {
|
|
|
225
245
|
// go-scaffold:user-handler-funcs
|
|
226
246
|
|
|
227
247
|
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
|
|
228
|
-
c.SetSameSite(
|
|
248
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
229
249
|
c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
|
|
230
250
|
}
|
|
231
251
|
|
|
232
252
|
func (h *Handler) clearRefreshCookie(c *gin.Context) {
|
|
233
|
-
c.SetSameSite(
|
|
253
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
234
254
|
c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
|
|
235
255
|
}
|
|
256
|
+
|
|
257
|
+
// sameSiteFrom maps COOKIE_SAMESITE onto the http constant, defaulting to the
|
|
258
|
+
// strictest option for anything it doesn't recognise.
|
|
259
|
+
//
|
|
260
|
+
// "strict" is right while the frontend and this API are the same site
|
|
261
|
+
// (localhost:3000 -> localhost:8080 is, and so is app.example.com ->
|
|
262
|
+
// api.example.com). A frontend on a genuinely different site — the usual
|
|
263
|
+
// vercel.app-plus-own-API-domain split — needs "none", because the browser
|
|
264
|
+
// will not attach a Strict or Lax cookie to the fetch that calls
|
|
265
|
+
// /auth/refresh: sessions then die at every access-token expiry with no
|
|
266
|
+
// error anywhere to explain it. "none" requires COOKIE_SECURE=true.
|
|
267
|
+
func sameSiteFrom(mode string) http.SameSite {
|
|
268
|
+
switch strings.ToLower(mode) {
|
|
269
|
+
case "none":
|
|
270
|
+
return http.SameSiteNoneMode
|
|
271
|
+
case "lax":
|
|
272
|
+
return http.SameSiteLaxMode
|
|
273
|
+
default:
|
|
274
|
+
return http.SameSiteStrictMode
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -3,6 +3,7 @@ package user
|
|
|
3
3
|
import (
|
|
4
4
|
"crypto/rand"
|
|
5
5
|
"crypto/sha256"
|
|
6
|
+
"crypto/subtle"
|
|
6
7
|
"encoding/hex"
|
|
7
8
|
"time"
|
|
8
9
|
|
|
@@ -22,6 +23,8 @@ const (
|
|
|
22
23
|
// presented where the other is expected.
|
|
23
24
|
type accessClaims struct {
|
|
24
25
|
Typ string `json:"typ"`
|
|
26
|
+
// Nonce is only set on the OAuth state token — see issueOAuthState.
|
|
27
|
+
Nonce string `json:"nonce,omitempty"`
|
|
25
28
|
// go-scaffold:jwt-claims
|
|
26
29
|
jwt.RegisteredClaims
|
|
27
30
|
}
|
|
@@ -42,21 +45,37 @@ func (s *Service) issueAccessToken(
|
|
|
42
45
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
// issueOAuthState / verifyOAuthState round-trip a short-lived
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
48
|
+
// issueOAuthState / verifyOAuthState round-trip a short-lived CSRF token
|
|
49
|
+
// through Google's own redirect. The signature and TTL are not the whole
|
|
50
|
+
// protection: a signed-but-unbound state is one anyone can fetch from
|
|
51
|
+
// GET /auth/google/login and then replay in a victim's browser, which is
|
|
52
|
+
// exactly the login-CSRF the state parameter exists to stop. So the token
|
|
53
|
+
// carries a nonce that the handler also drops in a short-lived httpOnly
|
|
54
|
+
// cookie, and the callback only proceeds when the two agree — the state is
|
|
55
|
+
// then usable in one browser only, the one that started the flow.
|
|
56
|
+
//
|
|
57
|
+
// Returns the state and the nonce to put in that cookie.
|
|
58
|
+
func (s *Service) issueOAuthState() (string, string, error) {
|
|
59
|
+
nonce, err := randomToken()
|
|
60
|
+
if err != nil {
|
|
61
|
+
return "", "", err
|
|
62
|
+
}
|
|
49
63
|
claims := accessClaims{
|
|
50
|
-
Typ:
|
|
64
|
+
Typ: tokenTypeOAuthState,
|
|
65
|
+
Nonce: nonce,
|
|
51
66
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
52
67
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
53
68
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
|
|
54
69
|
},
|
|
55
70
|
}
|
|
56
|
-
|
|
71
|
+
state, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
|
|
72
|
+
if err != nil {
|
|
73
|
+
return "", "", err
|
|
74
|
+
}
|
|
75
|
+
return state, nonce, nil
|
|
57
76
|
}
|
|
58
77
|
|
|
59
|
-
func (s *Service) verifyOAuthState(raw string) error {
|
|
78
|
+
func (s *Service) verifyOAuthState(raw, nonce string) error {
|
|
60
79
|
var claims accessClaims
|
|
61
80
|
_, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
|
|
62
81
|
return []byte(s.jwtSecret), nil
|
|
@@ -64,6 +83,11 @@ func (s *Service) verifyOAuthState(raw string) error {
|
|
|
64
83
|
if err != nil || claims.Typ != tokenTypeOAuthState {
|
|
65
84
|
return errInvalidToken()
|
|
66
85
|
}
|
|
86
|
+
// A missing cookie is a mismatch, not a pass — otherwise stripping the
|
|
87
|
+
// cookie is all it takes to get the old, unbound behavior back.
|
|
88
|
+
if nonce == "" || subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
|
|
89
|
+
return errInvalidToken()
|
|
90
|
+
}
|
|
67
91
|
return nil
|
|
68
92
|
}
|
|
69
93
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package model
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
"github.com/google/uuid"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// AuthToken is one short-lived token — the Postgres equivalent of the keys the
|
|
10
|
+
// Redis store would hold. Only the SHA-256 hash of the raw token is stored, so
|
|
11
|
+
// a database dump never yields a usable token.
|
|
12
|
+
//
|
|
13
|
+
// `Kind` mirrors the Redis key prefixes one-for-one, deliberately:
|
|
14
|
+
//
|
|
15
|
+
// refresh an active refresh token
|
|
16
|
+
// refresh_used a tombstone left by rotation, so replaying a rotated-out
|
|
17
|
+
// token is detectable as reuse rather than merely unknown
|
|
18
|
+
// pwreset password reset, consumed once
|
|
19
|
+
// emailverify email verification, consumed once
|
|
20
|
+
//
|
|
21
|
+
// Keeping the tombstone a separate row (rather than a used_at column on the
|
|
22
|
+
// refresh row) is what preserves logout's semantics: logout deletes the row
|
|
23
|
+
// outright and leaves no tombstone, so someone replaying a logged-out token
|
|
24
|
+
// gets a plain rejection instead of tripping reuse detection and nuking every
|
|
25
|
+
// session the user has.
|
|
26
|
+
//
|
|
27
|
+
// No foreign key to users on purpose: GORM's AutoMigrate (dev) wouldn't create
|
|
28
|
+
// one from these tags, and a constraint that exists in production but not in
|
|
29
|
+
// development is the exact mismatch that stops the app booting. A token whose
|
|
30
|
+
// user is gone simply fails the lookup that follows.
|
|
31
|
+
type AuthToken struct {
|
|
32
|
+
TokenHash string `gorm:"primaryKey;type:text"`
|
|
33
|
+
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
|
|
34
|
+
Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
|
|
35
|
+
ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
|
|
36
|
+
CreatedAt time.Time
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package model
|
|
2
|
+
|
|
3
|
+
import "time"
|
|
4
|
+
|
|
5
|
+
// LoginThrottle is the failed-attempt counter that makes lockout survive a
|
|
6
|
+
// deploy and mean the same thing on every replica. It is not the rate limiter
|
|
7
|
+
// — that one counts requests per IP and is allowed to be approximate. This
|
|
8
|
+
// counts failures per account, and per OWASP that is the control that actually
|
|
9
|
+
// stops credential stuffing: an attacker with a proxy pool keeps per-IP volume
|
|
10
|
+
// under any threshold you set, but they cannot spread attempts against one
|
|
11
|
+
// account across accounts.
|
|
12
|
+
//
|
|
13
|
+
// EmailHash, not the address: the counter has to be keyed on what the caller
|
|
14
|
+
// typed whether or not an account exists — otherwise "did this get throttled"
|
|
15
|
+
// answers "does this account exist" — and hashing means this table never
|
|
16
|
+
// becomes a directory of who has signed up.
|
|
17
|
+
type LoginThrottle struct {
|
|
18
|
+
EmailHash string `gorm:"primaryKey;type:text"`
|
|
19
|
+
Failures int `gorm:"not null;default:0"`
|
|
20
|
+
// nil until the free attempts are spent; afterwards it moves further out
|
|
21
|
+
// with each failure.
|
|
22
|
+
LockedUntil *time.Time
|
|
23
|
+
UpdatedAt time.Time
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
|
|
@@ -12,7 +12,10 @@ import (
|
|
|
12
12
|
// resolving to one account.
|
|
13
13
|
type User struct {
|
|
14
14
|
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
|
15
|
-
|
|
15
|
+
// index named explicitly, and named the same in create_users.up.sql: an
|
|
16
|
+
// anonymous `uniqueIndex` makes GORM invent one, and AutoMigrate then
|
|
17
|
+
// tries to DROP the differently-named constraint the migration created.
|
|
18
|
+
Email string `json:"email" gorm:"uniqueIndex:idx_users_email;not null"`
|
|
16
19
|
Name string `json:"name"`
|
|
17
20
|
AvatarURL string `json:"avatar_url"`
|
|
18
21
|
EmailVerified bool `json:"email_verified"`
|
|
@@ -20,3 +23,8 @@ type User struct {
|
|
|
20
23
|
CreatedAt time.Time `json:"created_at"`
|
|
21
24
|
UpdatedAt time.Time `json:"updated_at"`
|
|
22
25
|
}
|
|
26
|
+
|
|
27
|
+
// TableName pins this to user_svc rather than GORM's default inflection
|
|
28
|
+
// ("users", schema-less) — every domain gets its own schema, see
|
|
29
|
+
// docs/architect/patterns.md.
|
|
30
|
+
func (User) TableName() string { return "user_svc.users" }
|