@tatara/cli 0.1.0 → 0.1.2
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/dist/bin.mjs +323 -131
- package/package.json +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +154 -0
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +109 -0
- package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +36 -0
- package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +23 -0
- package/templates/features/authz/files/src/core/authz/constants.ts +4 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +5 -5
- package/templates/features/authz/files/src/core/authz/services/members.ts +17 -1
- package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
- package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
- package/templates/features/authz/files/src/core/authz/types.ts +2 -0
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +13 -0
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +7 -0
- package/templates/features/base/files/AGENTS.md +2 -0
- package/templates/features/base/files/Dockerfile +1 -0
- package/templates/features/base/files/_github/workflows/ci.yml +10 -0
- package/templates/features/base/files/src/core/commands/define.ts +9 -1
- package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
- package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
- package/templates/features/base/files/src/core/http/app.ts +6 -1
- package/templates/features/base/files/src/core/http/errors.ts +40 -12
- package/templates/features/base/files/src/core/http/middleware/validation.ts +1 -1
- package/templates/features/base/files/src/core/http/response.ts +5 -0
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
- package/templates/features/better-auth/files/src/core/auth/principal.ts +19 -1
- package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
- package/templates/features/better-auth/files/tests/helpers/auth.ts +2 -0
- package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
- package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
- package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
- package/templates/features/db/files/src/core/db/commands/reset.ts +2 -2
- package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +2 -2
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +3 -2
- package/templates/features/keycloak/files/src/core/auth/principal.ts +0 -27
- package/templates/features/keycloak/files/tests/helpers/auth.ts +0 -52
- package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +0 -18
- package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +0 -10
- package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +0 -10
- package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +0 -10
- package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +0 -13
- /package/templates/features/{keycloak → auth-keycloak}/files/keycloak/realm.json +0 -0
- /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/commands/keycloak-token.ts +0 -0
- /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/keycloak.ts +0 -0
package/dist/bin.mjs
CHANGED
|
@@ -90,6 +90,36 @@ function loadProject(from = process.cwd()) {
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
//#endregion
|
|
93
|
+
//#region src/commands/route-mounts.ts
|
|
94
|
+
const ROUTE_FILE = /\/routes\/[^/]+\.routes\.ts$/;
|
|
95
|
+
const DEFINE_ROUTES = /defineRoutes\(\s*\{([^}]*)\}/;
|
|
96
|
+
function lineOf$1(text, index) {
|
|
97
|
+
return text.slice(0, index).split("\n").length;
|
|
98
|
+
}
|
|
99
|
+
/** Route files that mount on an app + basePath already taken by an earlier file (sorted by path). */
|
|
100
|
+
function routeMountCollisions(sources) {
|
|
101
|
+
const owners = /* @__PURE__ */ new Map();
|
|
102
|
+
const violations = [];
|
|
103
|
+
for (const file of [...sources.keys()].sort()) {
|
|
104
|
+
if (!ROUTE_FILE.test(file)) continue;
|
|
105
|
+
const text = sources.get(file) ?? "";
|
|
106
|
+
const call = DEFINE_ROUTES.exec(text);
|
|
107
|
+
const app = call && /\bapp:\s*["']([^"']+)["']/.exec(call[1] ?? "")?.[1];
|
|
108
|
+
const basePath = call && /\bbasePath:\s*["']([^"']+)["']/.exec(call[1] ?? "")?.[1];
|
|
109
|
+
if (!call || !app || !basePath) continue;
|
|
110
|
+
const key = `${app} "${basePath}"`;
|
|
111
|
+
const owner = owners.get(key);
|
|
112
|
+
if (owner) violations.push({
|
|
113
|
+
file,
|
|
114
|
+
line: lineOf$1(text, call.index),
|
|
115
|
+
rule: "duplicate-base-path",
|
|
116
|
+
message: `${key} is already mounted by ${owner}; routers on one base path shadow each other, so give this one its own basePath`
|
|
117
|
+
});
|
|
118
|
+
else owners.set(key, file);
|
|
119
|
+
}
|
|
120
|
+
return violations;
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
93
123
|
//#region src/commands/check.ts
|
|
94
124
|
const IMPORT = /(?:import|export)\s[^"']*?from\s*["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g;
|
|
95
125
|
function lineOf(text, index) {
|
|
@@ -150,6 +180,7 @@ async function check(project = loadProject()) {
|
|
|
150
180
|
});
|
|
151
181
|
}
|
|
152
182
|
}
|
|
183
|
+
violations.push(...routeMountCollisions(sources));
|
|
153
184
|
return violations.filter((v) => !ignored(v, sources.get(v.file) ?? ""));
|
|
154
185
|
}
|
|
155
186
|
/** `// tatara-check-ignore <rule>: <reason>` on the line above a violation silences it. */
|
|
@@ -496,7 +527,7 @@ async function dev(project, flags = {}) {
|
|
|
496
527
|
if (project.has("admin")) urls.push(`Admin API http://localhost:${env.ADMIN_PORT ?? "3001"}/docs`);
|
|
497
528
|
if (project.has("email")) urls.push(`Mailpit http://localhost:${env.MAILPIT_UI_PORT ?? "8025"}`);
|
|
498
529
|
if (project.has("storage")) urls.push(`Storage UI http://localhost:${env.STORAGE_CONSOLE_PORT ?? "9001"}`);
|
|
499
|
-
if (project.has("keycloak")) urls.push(`Keycloak http://localhost:${env.KEYCLOAK_PORT ?? "8080"}/admin (admin/admin)`);
|
|
530
|
+
if (project.has("auth-keycloak")) urls.push(`Keycloak http://localhost:${env.KEYCLOAK_PORT ?? "8080"}/admin (admin/admin)`);
|
|
500
531
|
console.log(`\n${urls.map((u) => ` ${pc.green("➜")} ${u}`).join("\n")}\n`);
|
|
501
532
|
return new Promise((resolve) => {
|
|
502
533
|
const stop = () => {
|
|
@@ -522,7 +553,7 @@ const PACKAGES = {
|
|
|
522
553
|
drizzle: ["drizzle-orm", "drizzle-kit"],
|
|
523
554
|
prisma: ["@prisma/client", "prisma"],
|
|
524
555
|
"better-auth": ["better-auth"],
|
|
525
|
-
keycloak: ["jose"],
|
|
556
|
+
"auth-keycloak": ["jose"],
|
|
526
557
|
jobs: ["bullmq", "ioredis"]
|
|
527
558
|
};
|
|
528
559
|
async function doctor(project) {
|
|
@@ -835,7 +866,7 @@ const storage = {
|
|
|
835
866
|
env: [
|
|
836
867
|
{
|
|
837
868
|
key: "STORAGE_DRIVER",
|
|
838
|
-
schema: "z.enum([\"local\", \"s3\"])
|
|
869
|
+
schema: "localDefault(z.enum([\"local\", \"s3\"]), \"local\")",
|
|
839
870
|
example: "s3",
|
|
840
871
|
test: "local"
|
|
841
872
|
},
|
|
@@ -905,20 +936,29 @@ const storage = {
|
|
|
905
936
|
}
|
|
906
937
|
} },
|
|
907
938
|
composeVolumes: ["storage-data"],
|
|
908
|
-
slots: [
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
939
|
+
slots: [
|
|
940
|
+
{
|
|
941
|
+
file: "Dockerfile",
|
|
942
|
+
slot: "docker-runtime",
|
|
943
|
+
order: 50,
|
|
944
|
+
content: "RUN mkdir -p .storage && chown node:node .storage"
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
file: ".gitignore",
|
|
948
|
+
slot: "ignore",
|
|
949
|
+
content: ".storage/"
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
file: "AGENTS.md",
|
|
953
|
+
slot: "sections",
|
|
954
|
+
content: `## Storage
|
|
916
955
|
|
|
917
956
|
- Use \`storage\` from \`#core/storage/index\` (\`put\`, \`get\`, \`delete\`, \`url\`). \`STORAGE_DRIVER=s3\` works with
|
|
918
957
|
any S3-compatible store (RustFS locally, AWS S3, R2); \`local\` writes to \`STORAGE_LOCAL_DIR\` (tests).
|
|
919
958
|
- \`tatara run storage:check\` creates the bucket and does a round-trip.
|
|
920
959
|
`
|
|
921
|
-
|
|
960
|
+
}
|
|
961
|
+
]
|
|
922
962
|
};
|
|
923
963
|
const email = {
|
|
924
964
|
id: "email",
|
|
@@ -929,13 +969,13 @@ const email = {
|
|
|
929
969
|
env: [
|
|
930
970
|
{
|
|
931
971
|
key: "EMAIL_DRIVER",
|
|
932
|
-
schema: "z.enum([\"smtp\", \"console\"])
|
|
972
|
+
schema: "localDefault(z.enum([\"smtp\", \"console\"]), \"console\")",
|
|
933
973
|
example: "smtp",
|
|
934
974
|
test: "console"
|
|
935
975
|
},
|
|
936
976
|
{
|
|
937
977
|
key: "SMTP_URL",
|
|
938
|
-
schema: "z.string()
|
|
978
|
+
schema: "localDefault(z.string(), \"smtp://localhost:1025\")",
|
|
939
979
|
example: "smtp://localhost:1025"
|
|
940
980
|
},
|
|
941
981
|
{
|
|
@@ -1009,7 +1049,7 @@ const i18n = {
|
|
|
1009
1049
|
{
|
|
1010
1050
|
file: "src/core/http/middleware/error-handler.ts",
|
|
1011
1051
|
slot: "error-message",
|
|
1012
|
-
content: "message = t(c.get(\"language\"), `errors.${err.code}`, message);"
|
|
1052
|
+
content: "if (err.translatable) message = t(c.get(\"language\"), `errors.${err.code}`, message);"
|
|
1013
1053
|
},
|
|
1014
1054
|
{
|
|
1015
1055
|
file: "AGENTS.md",
|
|
@@ -1067,7 +1107,8 @@ const auth = {
|
|
|
1067
1107
|
};
|
|
1068
1108
|
const BETTER_AUTH_AGENTS = `### better-auth
|
|
1069
1109
|
|
|
1070
|
-
- Endpoints are served at \`/api/auth
|
|
1110
|
+
- Endpoints are served at \`/api/auth/*\` and listed in \`/openapi.json\` and \`/docs\` under the Auth tag
|
|
1111
|
+
(schemas prefixed \`Auth\`). better-auth's own reference is also at \`/api/auth/reference\`.
|
|
1071
1112
|
- Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
|
|
1072
1113
|
- Outside production, every email and SMS is kept in an in-memory outbox:
|
|
1073
1114
|
\`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
|
|
@@ -1113,6 +1154,16 @@ const betterAuth = {
|
|
|
1113
1154
|
slot: "imports",
|
|
1114
1155
|
content: "import { outboxEnabled, readOutbox } from \"#core/auth/delivery\";"
|
|
1115
1156
|
},
|
|
1157
|
+
{
|
|
1158
|
+
file: "src/core/http/app.ts",
|
|
1159
|
+
slot: "imports",
|
|
1160
|
+
content: "import { mergeAuthOpenApi } from \"#core/auth/openapi\";"
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
file: "src/core/http/app.ts",
|
|
1164
|
+
slot: "openapi",
|
|
1165
|
+
content: "if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);"
|
|
1166
|
+
},
|
|
1116
1167
|
{
|
|
1117
1168
|
file: "src/core/http/app.ts",
|
|
1118
1169
|
slot: "routes",
|
|
@@ -1288,6 +1339,64 @@ const authPassword = {
|
|
|
1288
1339
|
}
|
|
1289
1340
|
]
|
|
1290
1341
|
};
|
|
1342
|
+
const adminFields = [
|
|
1343
|
+
{
|
|
1344
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1345
|
+
slot: "user-fields",
|
|
1346
|
+
when: drizzlePg,
|
|
1347
|
+
content: "role: text(),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ withTimezone: true, mode: \"date\" }),"
|
|
1348
|
+
},
|
|
1349
|
+
{
|
|
1350
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1351
|
+
slot: "user-fields",
|
|
1352
|
+
when: drizzleMy,
|
|
1353
|
+
content: "role: varchar({ length: 255 }),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ fsp: 3, mode: \"date\" }),"
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1357
|
+
slot: "session-fields",
|
|
1358
|
+
when: drizzlePg,
|
|
1359
|
+
content: "activeOrganizationId: text(),\nimpersonatedBy: text(),"
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
file: "src/core/auth/db/auth.schema.ts",
|
|
1363
|
+
slot: "session-fields",
|
|
1364
|
+
when: drizzleMy,
|
|
1365
|
+
content: "activeOrganizationId: varchar({ length: 36 }),\nimpersonatedBy: varchar({ length: 36 }),"
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
file: "prisma/models/auth.prisma",
|
|
1369
|
+
slot: "user-fields",
|
|
1370
|
+
content: [
|
|
1371
|
+
"role String? @db.VarChar(255)",
|
|
1372
|
+
"banned Boolean? @default(false)",
|
|
1373
|
+
"banReason String? @map(\"ban_reason\") @db.Text",
|
|
1374
|
+
"banExpires DateTime? @map(\"ban_expires\")"
|
|
1375
|
+
].join("\n")
|
|
1376
|
+
},
|
|
1377
|
+
{
|
|
1378
|
+
file: "prisma/models/auth.prisma",
|
|
1379
|
+
slot: "user-relations",
|
|
1380
|
+
content: "members Member[]\ninvitations Invitation[]"
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
file: "prisma/models/auth.prisma",
|
|
1384
|
+
slot: "session-fields",
|
|
1385
|
+
content: ["activeOrganizationId String? @map(\"active_organization_id\") @db.VarChar(36)", "impersonatedBy String? @map(\"impersonated_by\") @db.VarChar(36)"].join("\n")
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
file: "src/core/auth/adapter.ts",
|
|
1389
|
+
slot: "imports",
|
|
1390
|
+
when: ["drizzle"],
|
|
1391
|
+
content: "import * as orgSchema from \"#core/auth/db/organization.schema\";"
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
file: "src/core/auth/adapter.ts",
|
|
1395
|
+
slot: "adapter-schema",
|
|
1396
|
+
when: ["drizzle"],
|
|
1397
|
+
content: "organization: orgSchema.organizations,\nmember: orgSchema.members,\ninvitation: orgSchema.invitations,"
|
|
1398
|
+
}
|
|
1399
|
+
];
|
|
1291
1400
|
const authOrgs = {
|
|
1292
1401
|
id: "auth-orgs",
|
|
1293
1402
|
kind: "auth-method",
|
|
@@ -1301,9 +1410,33 @@ const authOrgs = {
|
|
|
1301
1410
|
{
|
|
1302
1411
|
file: "src/core/auth/auth.ts",
|
|
1303
1412
|
slot: "plugins",
|
|
1413
|
+
unless: ["authz"],
|
|
1304
1414
|
content: "organization(),\nadmin(),",
|
|
1305
1415
|
order: 900
|
|
1306
1416
|
},
|
|
1417
|
+
{
|
|
1418
|
+
file: "src/core/auth/auth.ts",
|
|
1419
|
+
slot: "plugins",
|
|
1420
|
+
when: ["authz"],
|
|
1421
|
+
content: [
|
|
1422
|
+
"organization({",
|
|
1423
|
+
" organizationHooks: {",
|
|
1424
|
+
" // authz rows outlive better-auth's member row; without this, re-adding restores old roles",
|
|
1425
|
+
" afterRemoveMember: async ({ member }) => {",
|
|
1426
|
+
" await removeMemberAccess(member.organizationId, member.userId);",
|
|
1427
|
+
" },",
|
|
1428
|
+
" },",
|
|
1429
|
+
"}),",
|
|
1430
|
+
"admin(),"
|
|
1431
|
+
].join("\n"),
|
|
1432
|
+
order: 900
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
file: "src/core/auth/auth.ts",
|
|
1436
|
+
slot: "imports",
|
|
1437
|
+
when: ["authz"],
|
|
1438
|
+
content: "import { removeMemberAccess } from \"#core/authz/services/members\";"
|
|
1439
|
+
},
|
|
1307
1440
|
{
|
|
1308
1441
|
file: "tests/helpers/auth.ts",
|
|
1309
1442
|
slot: "user-fields",
|
|
@@ -1314,70 +1447,66 @@ const authOrgs = {
|
|
|
1314
1447
|
slot: "seed-user-fields",
|
|
1315
1448
|
content: "role: \"admin\","
|
|
1316
1449
|
},
|
|
1317
|
-
...
|
|
1318
|
-
{
|
|
1319
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1320
|
-
slot: "user-fields",
|
|
1321
|
-
when: drizzlePg,
|
|
1322
|
-
content: "role: text(),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ withTimezone: true, mode: \"date\" }),"
|
|
1323
|
-
},
|
|
1324
|
-
{
|
|
1325
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1326
|
-
slot: "user-fields",
|
|
1327
|
-
when: drizzleMy,
|
|
1328
|
-
content: "role: varchar({ length: 255 }),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ fsp: 3, mode: \"date\" }),"
|
|
1329
|
-
},
|
|
1330
|
-
{
|
|
1331
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1332
|
-
slot: "session-fields",
|
|
1333
|
-
when: drizzlePg,
|
|
1334
|
-
content: "activeOrganizationId: text(),\nimpersonatedBy: text(),"
|
|
1335
|
-
},
|
|
1336
|
-
{
|
|
1337
|
-
file: "src/core/auth/db/auth.schema.ts",
|
|
1338
|
-
slot: "session-fields",
|
|
1339
|
-
when: drizzleMy,
|
|
1340
|
-
content: "activeOrganizationId: varchar({ length: 36 }),\nimpersonatedBy: varchar({ length: 36 }),"
|
|
1341
|
-
},
|
|
1342
|
-
{
|
|
1343
|
-
file: "prisma/models/auth.prisma",
|
|
1344
|
-
slot: "user-fields",
|
|
1345
|
-
content: [
|
|
1346
|
-
"role String? @db.VarChar(255)",
|
|
1347
|
-
"banned Boolean? @default(false)",
|
|
1348
|
-
"banReason String? @map(\"ban_reason\") @db.Text",
|
|
1349
|
-
"banExpires DateTime? @map(\"ban_expires\")"
|
|
1350
|
-
].join("\n")
|
|
1351
|
-
},
|
|
1352
|
-
{
|
|
1353
|
-
file: "prisma/models/auth.prisma",
|
|
1354
|
-
slot: "user-relations",
|
|
1355
|
-
content: "members Member[]\ninvitations Invitation[]"
|
|
1356
|
-
},
|
|
1357
|
-
{
|
|
1358
|
-
file: "prisma/models/auth.prisma",
|
|
1359
|
-
slot: "session-fields",
|
|
1360
|
-
content: ["activeOrganizationId String? @map(\"active_organization_id\") @db.VarChar(36)", "impersonatedBy String? @map(\"impersonated_by\") @db.VarChar(36)"].join("\n")
|
|
1361
|
-
},
|
|
1362
|
-
{
|
|
1363
|
-
file: "src/core/auth/adapter.ts",
|
|
1364
|
-
slot: "imports",
|
|
1365
|
-
when: ["drizzle"],
|
|
1366
|
-
content: "import * as orgSchema from \"#core/auth/db/organization.schema\";"
|
|
1367
|
-
},
|
|
1368
|
-
{
|
|
1369
|
-
file: "src/core/auth/adapter.ts",
|
|
1370
|
-
slot: "adapter-schema",
|
|
1371
|
-
when: ["drizzle"],
|
|
1372
|
-
content: "organization: orgSchema.organizations,\nmember: orgSchema.members,\ninvitation: orgSchema.invitations,"
|
|
1373
|
-
}
|
|
1374
|
-
]
|
|
1450
|
+
...adminFields
|
|
1375
1451
|
]
|
|
1376
1452
|
};
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1453
|
+
const KEYCLOAK_AGENTS = `### Keycloak
|
|
1454
|
+
|
|
1455
|
+
- The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
|
|
1456
|
+
\`keycloakTokens()\` better-auth plugin (\`src/core/auth/keycloak-plugin.ts\`) verifies signature,
|
|
1457
|
+
issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
|
|
1458
|
+
against the \`__APP_NAME__-web\` client.
|
|
1459
|
+
- The token's subject is the user id. The user (and a \`keycloak\` account) is created in
|
|
1460
|
+
better-auth's tables on first use; a token whose email already belongs to another account is
|
|
1461
|
+
rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
|
|
1462
|
+
- Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
|
|
1463
|
+
a built-in authz role grants it. The organization comes from the \`organization\` claim.
|
|
1464
|
+
- Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
|
|
1465
|
+
\`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
|
|
1466
|
+
- Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
|
|
1467
|
+
`;
|
|
1468
|
+
const KEYCLOAK_TEST_HELPER = [
|
|
1469
|
+
"const { publicKey, privateKey } = await generateKeyPair(\"RS256\");",
|
|
1470
|
+
"setKeySource(async (header) => {",
|
|
1471
|
+
" if (header.kid !== \"test\") throw new Error(\"unknown key\");",
|
|
1472
|
+
" return publicKey;",
|
|
1473
|
+
"});",
|
|
1474
|
+
"",
|
|
1475
|
+
"export interface KeycloakTokenOptions {",
|
|
1476
|
+
" sub?: string;",
|
|
1477
|
+
" email?: string;",
|
|
1478
|
+
" emailVerified?: boolean;",
|
|
1479
|
+
" roles?: string[];",
|
|
1480
|
+
" organization?: string[] | Record<string, unknown>;",
|
|
1481
|
+
" username?: string;",
|
|
1482
|
+
" issuer?: string;",
|
|
1483
|
+
" audience?: string;",
|
|
1484
|
+
" expiresIn?: string | number;",
|
|
1485
|
+
"}",
|
|
1486
|
+
"",
|
|
1487
|
+
"/** Signs an access token the API accepts, as if Keycloak issued it. */",
|
|
1488
|
+
"export async function keycloakToken(options: KeycloakTokenOptions = {}): Promise<string> {",
|
|
1489
|
+
" const sub = options.sub ?? newId();",
|
|
1490
|
+
" return new SignJWT({",
|
|
1491
|
+
" email: options.email ?? `user-${sub}@example.com`,",
|
|
1492
|
+
" email_verified: options.emailVerified ?? true,",
|
|
1493
|
+
" preferred_username: options.username ?? `user-${sub.slice(0, 8)}`,",
|
|
1494
|
+
" roles: options.roles ?? [\"user\"],",
|
|
1495
|
+
" ...(options.organization ? { organization: options.organization } : {}),",
|
|
1496
|
+
" })",
|
|
1497
|
+
" .setProtectedHeader({ alg: \"RS256\", kid: \"test\" })",
|
|
1498
|
+
" .setSubject(sub)",
|
|
1499
|
+
" .setIssuer(options.issuer ?? issuer)",
|
|
1500
|
+
" .setAudience(options.audience ?? env.KEYCLOAK_AUDIENCE)",
|
|
1501
|
+
" .setIssuedAt()",
|
|
1502
|
+
" .setExpirationTime(options.expiresIn ?? \"5m\")",
|
|
1503
|
+
" .sign(privateKey);",
|
|
1504
|
+
"}"
|
|
1505
|
+
].join("\n");
|
|
1506
|
+
const authKeycloak = {
|
|
1507
|
+
id: "auth-keycloak",
|
|
1508
|
+
kind: "auth-method",
|
|
1509
|
+
label: "Keycloak tokens",
|
|
1381
1510
|
dependencies: ["jose"],
|
|
1382
1511
|
env: [
|
|
1383
1512
|
{
|
|
@@ -1389,7 +1518,7 @@ const keycloak = {
|
|
|
1389
1518
|
},
|
|
1390
1519
|
{
|
|
1391
1520
|
key: "KEYCLOAK_URL",
|
|
1392
|
-
schema: "z.url()
|
|
1521
|
+
schema: "localDefault(z.url(), \"http://localhost:8080\")",
|
|
1393
1522
|
example: "http://localhost:8080"
|
|
1394
1523
|
},
|
|
1395
1524
|
{
|
|
@@ -1425,30 +1554,72 @@ const keycloak = {
|
|
|
1425
1554
|
} },
|
|
1426
1555
|
slots: [
|
|
1427
1556
|
{
|
|
1428
|
-
file: "src/core/auth/
|
|
1429
|
-
slot: "
|
|
1430
|
-
|
|
1431
|
-
|
|
1557
|
+
file: "src/core/auth/auth.ts",
|
|
1558
|
+
slot: "imports",
|
|
1559
|
+
content: "import { keycloakTokens } from \"#core/auth/keycloak-plugin\";"
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
file: "src/core/auth/auth.ts",
|
|
1563
|
+
slot: "plugins",
|
|
1564
|
+
unless: ["auth-orgs"],
|
|
1565
|
+
content: "keycloakTokens(),",
|
|
1566
|
+
order: 800
|
|
1432
1567
|
},
|
|
1433
1568
|
{
|
|
1434
|
-
file: "src/core/auth/
|
|
1435
|
-
slot: "
|
|
1436
|
-
when: ["
|
|
1437
|
-
content: "
|
|
1569
|
+
file: "src/core/auth/auth.ts",
|
|
1570
|
+
slot: "plugins",
|
|
1571
|
+
when: ["auth-orgs"],
|
|
1572
|
+
content: "keycloakTokens({ organizations: true }),",
|
|
1573
|
+
order: 800
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
file: "src/core/auth/principal.ts",
|
|
1577
|
+
slot: "no-session",
|
|
1578
|
+
content: [
|
|
1579
|
+
"if (!session) {",
|
|
1580
|
+
" const header = c.req.header(\"authorization\");",
|
|
1581
|
+
" const token = header?.startsWith(\"Bearer \") ? header.slice(7) : undefined;",
|
|
1582
|
+
" const principal = token ? await auth.api.verifyKeycloakToken({ body: { token } }) : null;",
|
|
1583
|
+
" if (principal) c.set(\"principal\", principal);",
|
|
1584
|
+
"}"
|
|
1585
|
+
].join("\n")
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
file: "tests/helpers/auth.ts",
|
|
1589
|
+
slot: "imports",
|
|
1590
|
+
content: "import { generateKeyPair, SignJWT } from \"jose\";"
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
file: "tests/helpers/auth.ts",
|
|
1594
|
+
slot: "imports",
|
|
1595
|
+
content: "import { issuer, setKeySource } from \"#core/auth/keycloak\";"
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
file: "tests/helpers/auth.ts",
|
|
1599
|
+
slot: "imports",
|
|
1600
|
+
content: "import { env } from \"#core/config/env\";"
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
file: "tests/helpers/auth.ts",
|
|
1604
|
+
slot: "helpers",
|
|
1605
|
+
content: KEYCLOAK_TEST_HELPER
|
|
1606
|
+
},
|
|
1607
|
+
{
|
|
1608
|
+
file: "tests/helpers/auth.ts",
|
|
1609
|
+
slot: "authenticate",
|
|
1610
|
+
unless: ["auth-orgs"],
|
|
1611
|
+
content: [
|
|
1612
|
+
"if (options.roles) {",
|
|
1613
|
+
" const sub = newId();",
|
|
1614
|
+
" const token = await keycloakToken({ sub, roles: options.roles, email: options.email });",
|
|
1615
|
+
" return { userId: sub, headers: { authorization: `Bearer ${token}` } };",
|
|
1616
|
+
"}"
|
|
1617
|
+
].join("\n")
|
|
1438
1618
|
},
|
|
1439
1619
|
{
|
|
1440
1620
|
file: "AGENTS.md",
|
|
1441
1621
|
slot: "sections",
|
|
1442
|
-
content:
|
|
1443
|
-
|
|
1444
|
-
- The API is a resource server: it verifies Keycloak access tokens (signature, issuer,
|
|
1445
|
-
audience \`__APP_NAME__-api\`, expiry). Login happens in the frontend via OIDC (PKCE) against
|
|
1446
|
-
the \`__APP_NAME__-web\` client.
|
|
1447
|
-
- Roles come from the token's \`roles\` claim (realm roles) and client roles for the API.
|
|
1448
|
-
- Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
|
|
1449
|
-
\`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
|
|
1450
|
-
- Tests sign tokens with a local key; see \`#tests/helpers/auth\`.
|
|
1451
|
-
`
|
|
1622
|
+
content: KEYCLOAK_AGENTS
|
|
1452
1623
|
}
|
|
1453
1624
|
]
|
|
1454
1625
|
};
|
|
@@ -1509,13 +1680,13 @@ const base = {
|
|
|
1509
1680
|
},
|
|
1510
1681
|
{
|
|
1511
1682
|
key: "APP_URL",
|
|
1512
|
-
schema: "z.url()
|
|
1683
|
+
schema: "localDefault(z.url(), \"http://localhost:3000\")",
|
|
1513
1684
|
example: "http://localhost:3000",
|
|
1514
1685
|
comment: "Public URL of the API"
|
|
1515
1686
|
},
|
|
1516
1687
|
{
|
|
1517
1688
|
key: "CORS_ORIGINS",
|
|
1518
|
-
schema: "z.string()
|
|
1689
|
+
schema: "localDefault(z.string(), \"http://localhost:5173\").transform((v) => v.split(\",\").map((o) => o.trim()).filter(Boolean))",
|
|
1519
1690
|
example: "http://localhost:5173",
|
|
1520
1691
|
comment: "Comma-separated browser origins allowed to call the API"
|
|
1521
1692
|
},
|
|
@@ -1550,7 +1721,7 @@ const pnpm = {
|
|
|
1550
1721
|
slot: "docker-build",
|
|
1551
1722
|
content: [
|
|
1552
1723
|
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
|
|
1553
|
-
"RUN pnpm install --frozen-lockfile",
|
|
1724
|
+
"RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-cache,target=/root/.cache/pnpm pnpm install --frozen-lockfile",
|
|
1554
1725
|
"COPY . .",
|
|
1555
1726
|
"RUN pnpm run build"
|
|
1556
1727
|
].join("\n")
|
|
@@ -1561,7 +1732,8 @@ const pnpm = {
|
|
|
1561
1732
|
order: 0,
|
|
1562
1733
|
content: [
|
|
1563
1734
|
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
|
|
1564
|
-
"
|
|
1735
|
+
"# cache mounts keep the package store out of the image",
|
|
1736
|
+
"RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-cache,target=/root/.cache/pnpm pnpm install --frozen-lockfile --prod",
|
|
1565
1737
|
"COPY --from=build /app/dist ./dist"
|
|
1566
1738
|
].join("\n")
|
|
1567
1739
|
},
|
|
@@ -1589,7 +1761,7 @@ const npm = {
|
|
|
1589
1761
|
slot: "docker-build",
|
|
1590
1762
|
content: [
|
|
1591
1763
|
"COPY package.json package-lock.json ./",
|
|
1592
|
-
"RUN npm ci",
|
|
1764
|
+
"RUN --mount=type=cache,target=/root/.npm npm ci",
|
|
1593
1765
|
"COPY . .",
|
|
1594
1766
|
"RUN npm run build"
|
|
1595
1767
|
].join("\n")
|
|
@@ -1600,7 +1772,7 @@ const npm = {
|
|
|
1600
1772
|
order: 0,
|
|
1601
1773
|
content: [
|
|
1602
1774
|
"COPY package.json package-lock.json ./",
|
|
1603
|
-
"RUN npm ci --omit=dev",
|
|
1775
|
+
"RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev",
|
|
1604
1776
|
"COPY --from=build /app/dist ./dist"
|
|
1605
1777
|
].join("\n")
|
|
1606
1778
|
},
|
|
@@ -1632,7 +1804,7 @@ const bun = {
|
|
|
1632
1804
|
slot: "docker-build",
|
|
1633
1805
|
content: [
|
|
1634
1806
|
"COPY package.json bun.lock ./",
|
|
1635
|
-
"RUN bun install --frozen-lockfile",
|
|
1807
|
+
"RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile",
|
|
1636
1808
|
"COPY . .",
|
|
1637
1809
|
"RUN bun run build"
|
|
1638
1810
|
].join("\n")
|
|
@@ -1643,7 +1815,7 @@ const bun = {
|
|
|
1643
1815
|
order: 0,
|
|
1644
1816
|
content: [
|
|
1645
1817
|
"COPY package.json bun.lock ./",
|
|
1646
|
-
"RUN bun install --frozen-lockfile --production",
|
|
1818
|
+
"RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --production",
|
|
1647
1819
|
"COPY --from=build /app/dist ./dist"
|
|
1648
1820
|
].join("\n")
|
|
1649
1821
|
},
|
|
@@ -1837,7 +2009,7 @@ const FEATURES = Object.fromEntries([
|
|
|
1837
2009
|
authPhoneOtp,
|
|
1838
2010
|
authPassword,
|
|
1839
2011
|
authOrgs,
|
|
1840
|
-
|
|
2012
|
+
authKeycloak,
|
|
1841
2013
|
auth,
|
|
1842
2014
|
authz,
|
|
1843
2015
|
jobs,
|
|
@@ -1861,12 +2033,12 @@ function validateOptions(input) {
|
|
|
1861
2033
|
const hasDb = options.database !== "none";
|
|
1862
2034
|
if (options.auth !== "none" && !hasDb) throw new OptionsError(`Auth (${options.auth}) needs a database.`);
|
|
1863
2035
|
if (options.auth === "better-auth") {
|
|
1864
|
-
if (options.authMethods.filter((m) => m !== "orgs").length === 0) throw new OptionsError("better-auth needs at least one sign-in method: email-otp, phone-otp or
|
|
2036
|
+
if (options.authMethods.filter((m) => m !== "orgs").length === 0) throw new OptionsError("better-auth needs at least one sign-in method: email-otp, phone-otp, password or keycloak.");
|
|
1865
2037
|
} else if (options.authMethods.length > 0) throw new OptionsError("Auth methods only apply to better-auth.");
|
|
1866
2038
|
for (const addon of options.addons) {
|
|
1867
2039
|
if ((addon === "authz" || addon === "admin") && options.auth === "none") throw new OptionsError(`The ${addon} add-on needs an auth provider.`);
|
|
1868
2040
|
if (addon === "authz" && !hasDb) throw new OptionsError("The authz add-on needs a database.");
|
|
1869
|
-
if (addon === "admin" && options.auth === "better-auth" && !options.authMethods.includes("orgs")) throw new OptionsError("The admin add-on with better-auth needs the \"orgs\" method (
|
|
2041
|
+
if (addon === "admin" && options.auth === "better-auth" && !options.authMethods.includes("orgs") && !options.authMethods.includes("keycloak")) throw new OptionsError("The admin add-on with better-auth needs the \"orgs\" or \"keycloak\" method (they provide user roles).");
|
|
1870
2042
|
}
|
|
1871
2043
|
return options;
|
|
1872
2044
|
}
|
|
@@ -2002,6 +2174,13 @@ function envModule(vars) {
|
|
|
2002
2174
|
return `import { createEnv } from "@t3-oss/env-core";
|
|
2003
2175
|
import { z } from "zod";
|
|
2004
2176
|
|
|
2177
|
+
const production = process.env.NODE_ENV === "production";
|
|
2178
|
+
|
|
2179
|
+
/** A default for local development; production must set the value explicitly. */
|
|
2180
|
+
function localDefault<T extends z.ZodType>(schema: T, value: z.input<T>): T {
|
|
2181
|
+
return production ? schema : (schema.default(value as never) as unknown as T);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2005
2184
|
/** The only place that reads process.env. Add new variables here and to .env.example. */
|
|
2006
2185
|
export const env = createEnv({
|
|
2007
2186
|
server: {
|
|
@@ -2370,6 +2549,13 @@ async function generate(project, request) {
|
|
|
2370
2549
|
}
|
|
2371
2550
|
const existing = [...rendered.keys()].filter((rel) => existsSync(join(project.root, rel)));
|
|
2372
2551
|
if (existing.length > 0) throw new GeneratorError(`Refusing to overwrite existing file(s):\n ${existing.join("\n ")}`);
|
|
2552
|
+
const routeSources = /* @__PURE__ */ new Map();
|
|
2553
|
+
for (const rel of await glob("src/**/routes/*.routes.ts", { cwd: project.root })) routeSources.set(rel, await readFile(join(project.root, rel), "utf8"));
|
|
2554
|
+
const before = routeMountCollisions(routeSources).length;
|
|
2555
|
+
for (const [rel, text] of rendered) routeSources.set(rel, text);
|
|
2556
|
+
const after = routeMountCollisions(routeSources);
|
|
2557
|
+
const collision = after.length > before ? after.at(-1) : void 0;
|
|
2558
|
+
if (collision) throw new GeneratorError(`${collision.file}: ${collision.message}. Pick another --name, or create it and change basePath.`);
|
|
2373
2559
|
const files = [...rendered.keys()].sort();
|
|
2374
2560
|
if (request.dryRun) return {
|
|
2375
2561
|
kind: request.kind,
|
|
@@ -2459,17 +2645,25 @@ const DATABASES = [
|
|
|
2459
2645
|
"mysql",
|
|
2460
2646
|
"none"
|
|
2461
2647
|
];
|
|
2462
|
-
const AUTHS = [
|
|
2463
|
-
"better-auth",
|
|
2464
|
-
"keycloak",
|
|
2465
|
-
"none"
|
|
2466
|
-
];
|
|
2648
|
+
const AUTHS = ["better-auth", "none"];
|
|
2467
2649
|
const METHODS = [
|
|
2468
2650
|
"email-otp",
|
|
2469
2651
|
"phone-otp",
|
|
2470
2652
|
"password",
|
|
2653
|
+
"keycloak",
|
|
2471
2654
|
"orgs"
|
|
2472
2655
|
];
|
|
2656
|
+
/** `--auth keycloak` (before 0.1.2) is better-auth with the keycloak method. */
|
|
2657
|
+
function normalizeAuthFlags(flags) {
|
|
2658
|
+
if (flags.auth !== "keycloak") return flags;
|
|
2659
|
+
const methods = new Set((flags.authMethods ?? "").split(",").filter(Boolean));
|
|
2660
|
+
methods.add("keycloak");
|
|
2661
|
+
return {
|
|
2662
|
+
...flags,
|
|
2663
|
+
auth: "better-auth",
|
|
2664
|
+
authMethods: [...methods].join(",")
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2473
2667
|
const ADDONS = [
|
|
2474
2668
|
"authz",
|
|
2475
2669
|
"jobs",
|
|
@@ -2537,22 +2731,14 @@ async function prompt(name, flags) {
|
|
|
2537
2731
|
let auth = "none";
|
|
2538
2732
|
if (database !== "none") auth = flags.auth ? oneOf(flags.auth, AUTHS, "auth") : answer(await p.select({
|
|
2539
2733
|
message: "Auth",
|
|
2540
|
-
options: [
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
label: "Keycloak",
|
|
2549
|
-
hint: "API verifies Keycloak tokens"
|
|
2550
|
-
},
|
|
2551
|
-
{
|
|
2552
|
-
value: "none",
|
|
2553
|
-
label: "None"
|
|
2554
|
-
}
|
|
2555
|
-
]
|
|
2734
|
+
options: [{
|
|
2735
|
+
value: "better-auth",
|
|
2736
|
+
label: "better-auth",
|
|
2737
|
+
hint: "sign-in handled by the API"
|
|
2738
|
+
}, {
|
|
2739
|
+
value: "none",
|
|
2740
|
+
label: "None"
|
|
2741
|
+
}]
|
|
2556
2742
|
}));
|
|
2557
2743
|
let authMethods = [];
|
|
2558
2744
|
if (auth === "better-auth") authMethods = flags.authMethods ? listOf(flags.authMethods, METHODS, "auth-methods") : answer(await p.multiselect({
|
|
@@ -2571,6 +2757,11 @@ async function prompt(name, flags) {
|
|
|
2571
2757
|
value: "password",
|
|
2572
2758
|
label: "Email + password"
|
|
2573
2759
|
},
|
|
2760
|
+
{
|
|
2761
|
+
value: "keycloak",
|
|
2762
|
+
label: "Keycloak tokens",
|
|
2763
|
+
hint: "API accepts Keycloak access tokens"
|
|
2764
|
+
},
|
|
2574
2765
|
{
|
|
2575
2766
|
value: "orgs",
|
|
2576
2767
|
label: "Organizations + roles",
|
|
@@ -2645,7 +2836,8 @@ async function run(cmd, args, cwd, label) {
|
|
|
2645
2836
|
}
|
|
2646
2837
|
spinner?.stop(label);
|
|
2647
2838
|
}
|
|
2648
|
-
async function newApp(dir,
|
|
2839
|
+
async function newApp(dir, rawFlags) {
|
|
2840
|
+
const flags = normalizeAuthFlags(rawFlags);
|
|
2649
2841
|
const interactive = !flags.yes && process.stdout.isTTY;
|
|
2650
2842
|
if (interactive) p.intro(pc.bold("tatara"));
|
|
2651
2843
|
let target = dir;
|
|
@@ -2826,7 +3018,7 @@ server.on("exit", () => process.exit(0));
|
|
|
2826
3018
|
//#endregion
|
|
2827
3019
|
//#region src/bin.ts
|
|
2828
3020
|
const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
|
|
2829
|
-
program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | keycloak
|
|
3021
|
+
program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | none (keycloak = better-auth with --auth-methods keycloak)").option("--auth-methods <list>", "email-otp,phone-otp,password,orgs (better-auth)").option("--addons <list>", "authz,jobs,admin,storage,email,i18n").option("--pm <pm>", "pnpm | npm | bun").option("-y, --yes", "skip prompts and use flags/defaults").option("--no-install", "do not install dependencies").option("--no-git", "do not initialise git").action(async (dir, flags) => {
|
|
2830
3022
|
await newApp(dir, flags);
|
|
2831
3023
|
});
|
|
2832
3024
|
program.command("sync").description("Regenerate src/generated from discovered module files").option("--quiet", "no output").action(async (flags) => {
|