@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.
Files changed (45) hide show
  1. package/dist/bin.mjs +323 -131
  2. package/package.json +1 -1
  3. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +154 -0
  4. package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +109 -0
  5. package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +36 -0
  6. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +23 -0
  7. package/templates/features/authz/files/src/core/authz/constants.ts +4 -0
  8. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +5 -5
  9. package/templates/features/authz/files/src/core/authz/services/members.ts +17 -1
  10. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  11. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  12. package/templates/features/authz/files/src/core/authz/types.ts +2 -0
  13. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +13 -0
  14. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +7 -0
  15. package/templates/features/base/files/AGENTS.md +2 -0
  16. package/templates/features/base/files/Dockerfile +1 -0
  17. package/templates/features/base/files/_github/workflows/ci.yml +10 -0
  18. package/templates/features/base/files/src/core/commands/define.ts +9 -1
  19. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  20. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  21. package/templates/features/base/files/src/core/http/app.ts +6 -1
  22. package/templates/features/base/files/src/core/http/errors.ts +40 -12
  23. package/templates/features/base/files/src/core/http/middleware/validation.ts +1 -1
  24. package/templates/features/base/files/src/core/http/response.ts +5 -0
  25. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  26. package/templates/features/better-auth/files/src/core/auth/principal.ts +19 -1
  27. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  28. package/templates/features/better-auth/files/tests/helpers/auth.ts +2 -0
  29. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  30. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  31. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  32. package/templates/features/db/files/src/core/db/commands/reset.ts +2 -2
  33. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  34. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +2 -2
  35. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +3 -2
  36. package/templates/features/keycloak/files/src/core/auth/principal.ts +0 -27
  37. package/templates/features/keycloak/files/tests/helpers/auth.ts +0 -52
  38. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +0 -18
  39. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +0 -10
  40. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +0 -10
  41. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +0 -10
  42. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +0 -13
  43. /package/templates/features/{keycloak → auth-keycloak}/files/keycloak/realm.json +0 -0
  44. /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/commands/keycloak-token.ts +0 -0
  45. /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\"]).default(\"local\")",
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
- file: ".gitignore",
910
- slot: "ignore",
911
- content: ".storage/"
912
- }, {
913
- file: "AGENTS.md",
914
- slot: "sections",
915
- content: `## Storage
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\"]).default(\"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().default(\"smtp://localhost:1025\")",
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/*\`; their reference is at \`/api/auth/reference\`.
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 keycloak = {
1378
- id: "keycloak",
1379
- kind: "auth",
1380
- label: "Keycloak",
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().default(\"http://localhost:8080\")",
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/users.ts",
1429
- slot: "upsert",
1430
- when: ["drizzle", "postgres"],
1431
- content: "await db\n .insert(users)\n .values(values)\n .onConflictDoUpdate({ target: users.id, set: { email: values.email, name: values.name, updatedAt: new Date() } });"
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/users.ts",
1435
- slot: "upsert",
1436
- when: ["drizzle", "mysql"],
1437
- content: "await db\n .insert(users)\n .values(values)\n .onDuplicateKeyUpdate({ set: { email: values.email, name: values.name } });"
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: `### Keycloak
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().default(\"http://localhost:3000\")",
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().default(\"http://localhost:5173\").transform((v) => v.split(\",\").map((o) => o.trim()).filter(Boolean))",
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
- "RUN pnpm install --frozen-lockfile --prod",
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
- keycloak,
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 password.");
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 (it provides user roles).");
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
- value: "better-auth",
2543
- label: "better-auth",
2544
- hint: "sign-in handled by the API"
2545
- },
2546
- {
2547
- value: "keycloak",
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, flags) {
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 | none").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) => {
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) => {