@tatara/cli 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/bin.mjs +358 -90
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/apps/admin/app.ts +1 -1
  4. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +2 -2
  5. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +11 -4
  6. package/templates/features/admin/files/src/modules/staff/db/seed.ts +27 -0
  7. package/templates/features/admin/files/src/modules/staff/services/auth.ts +46 -0
  8. package/templates/features/admin/files/tests/helpers/staff.ts +8 -0
  9. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/db/staff.schema.ts +58 -0
  10. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/services/adapter.ts +13 -0
  11. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/db/staff.schema.ts +57 -0
  12. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/services/adapter.ts +13 -0
  13. package/templates/features/admin/files+prisma/prisma/models/staff.prisma +61 -0
  14. package/templates/features/admin/files+prisma+mysql/src/modules/staff/services/adapter.ts +5 -0
  15. package/templates/features/admin/files+prisma+postgres/src/modules/staff/services/adapter.ts +5 -0
  16. package/templates/features/auth-keycloak/files/keycloak/realm.json +70 -9
  17. package/templates/features/auth-keycloak/files/src/{core/auth → modules/users}/commands/keycloak-token.ts +3 -1
  18. package/templates/features/auth-keycloak/files/{src/core/auth/tests/keycloak-tokens.test.ts → tests/tatara/auth-keycloak-tokens.test.ts} +2 -2
  19. package/templates/features/auth-keycloak/files+auth-orgs/{src/core/auth/tests/keycloak-organizations.test.ts → tests/tatara/auth-keycloak-organizations.test.ts} +1 -1
  20. package/templates/features/auth-keycloak/files+authz/{src/core/auth/tests/keycloak-authz.test.ts → tests/tatara/auth-keycloak-authz.test.ts} +3 -2
  21. package/templates/features/auth-orgs/files+drizzle+mysql/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  22. package/templates/features/auth-orgs/files+drizzle+postgres/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  23. package/templates/features/authz/files/src/core/authz/system-roles.ts +1 -0
  24. package/templates/features/authz/files/src/{core/authz → modules/users}/routes/authorization.routes.ts +4 -1
  25. package/templates/features/authz/files/{src/core/authz/tests → tests/tatara}/authz.test.ts +4 -2
  26. package/templates/features/authz/files+drizzle/src/core/authz/index.ts +1 -0
  27. package/templates/features/authz/files+prisma/src/core/authz/index.ts +1 -0
  28. package/templates/features/base/files/src/apps/api/app.ts +1 -1
  29. package/templates/features/base/files/src/core/http/context.ts +2 -0
  30. package/templates/features/better-auth/files/src/{core/auth → modules/users}/db/seed.ts +1 -1
  31. package/templates/features/better-auth/files/src/modules/users/services/auth.ts +37 -0
  32. package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/delivery.ts +1 -1
  33. package/templates/features/better-auth/files/tests/helpers/auth.ts +4 -3
  34. package/templates/features/better-auth/files+admin/tests/tatara/auth-openapi-admin.test.ts +10 -0
  35. package/templates/features/better-auth/files+auth-orgs/{src/core/auth/tests/principal.test.ts → tests/tatara/auth-principal.test.ts} +1 -1
  36. package/templates/features/better-auth/files+auth-orgs+authz/{src/core/auth/tests/principal-authz.test.ts → tests/tatara/auth-principal-authz.test.ts} +1 -1
  37. package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  38. package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  39. package/templates/versions.json +4 -4
  40. package/templates/features/better-auth/files/src/core/auth/auth.ts +0 -26
  41. package/templates/features/better-auth/files/src/core/auth/principal.ts +0 -10
  42. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +0 -8
  43. /package/templates/features/auth/files/src/{core/auth → modules/users}/routes/me.routes.ts +0 -0
  44. /package/templates/features/auth/files/{src/core/auth/tests/me.test.ts → tests/tatara/auth-me.test.ts} +0 -0
  45. /package/templates/features/auth-keycloak/files/src/{core/auth → modules/users/services}/keycloak.ts +0 -0
  46. /package/templates/features/authz/files/{src/core/authz/tests/error-messages.test.ts → tests/tatara/authz-error-messages.test.ts} +0 -0
  47. /package/templates/features/authz/files/{src/core/authz/tests/request-body.test.ts → tests/tatara/authz-request-body.test.ts} +0 -0
  48. /package/templates/features/base/files/src/{core/http/app.ts → apps/http.ts} +0 -0
  49. /package/templates/features/base/files/{src/core/config/tests/env.test.ts → tests/tatara/config-env.test.ts} +0 -0
  50. /package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/sms.ts +0 -0
  51. /package/templates/features/better-auth/files/{src/core/auth/tests/openapi.test.ts → tests/tatara/auth-openapi.test.ts} +0 -0
  52. /package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  53. /package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  54. /package/templates/features/better-auth/files+prisma+mysql/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  55. /package/templates/features/better-auth/files+prisma+postgres/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  56. /package/templates/features/email/files/{src/core/email/tests → tests/tatara}/email.test.ts +0 -0
  57. /package/templates/features/i18n/files/{src/core/i18n/tests → tests/tatara}/i18n.test.ts +0 -0
  58. /package/templates/features/storage/files/{src/core/storage/tests → tests/tatara}/storage.test.ts +0 -0
package/dist/bin.mjs CHANGED
@@ -865,6 +865,24 @@ const authz = {
865
865
  label: "Permission system",
866
866
  dependencies: ["@tatara/authz"],
867
867
  slots: [
868
+ {
869
+ file: "src/core/authz/index.ts",
870
+ slot: "options",
871
+ unless: ["auth-orgs"],
872
+ content: "singleTenant: \"default\","
873
+ },
874
+ {
875
+ file: "src/modules/users/routes/authorization.routes.ts",
876
+ slot: "route-options",
877
+ when: ["auth-orgs"],
878
+ content: "use: [requireOrg()],"
879
+ },
880
+ {
881
+ file: "src/modules/users/routes/authorization.routes.ts",
882
+ slot: "imports",
883
+ when: ["auth-orgs"],
884
+ content: "import { requireOrg } from \"#modules/users/services/auth\";"
885
+ },
868
886
  {
869
887
  file: "tests/helpers/auth.ts",
870
888
  slot: "imports",
@@ -877,14 +895,15 @@ const authz = {
877
895
  },
878
896
  {
879
897
  file: "tests/helpers/auth.ts",
880
- slot: "options",
881
- when: ["better-auth"],
882
- content: "orgId?: string;"
898
+ slot: "as-user",
899
+ unless: ["auth-orgs"],
900
+ content: "await applyTestAccess(user.userId, options);"
883
901
  },
884
902
  {
885
903
  file: "tests/helpers/auth.ts",
886
904
  slot: "as-user",
887
- content: "await applyTestAccess(user.userId, options);"
905
+ when: ["auth-orgs"],
906
+ content: "await applyTestAccess(user.userId, { ...options, orgId: user.orgId });"
888
907
  },
889
908
  {
890
909
  file: "AGENTS.md",
@@ -1001,11 +1020,32 @@ const admin = {
1001
1020
  kind: "addon",
1002
1021
  label: "Admin API app",
1003
1022
  scripts: { "start:admin": "node dist/apps/admin/server.mjs" },
1004
- env: [{
1005
- key: "ADMIN_PORT",
1006
- schema: "z.coerce.number().int().default(3001)",
1007
- example: "3001"
1008
- }],
1023
+ env: [
1024
+ {
1025
+ key: "ADMIN_PORT",
1026
+ schema: "z.coerce.number().int().default(3001)",
1027
+ example: "3001"
1028
+ },
1029
+ {
1030
+ key: "ADMIN_URL",
1031
+ schema: "localDefault(z.url(), \"http://localhost:3001\")",
1032
+ example: "http://localhost:3001",
1033
+ comment: "Where the admin app is served; staff sign in there"
1034
+ },
1035
+ {
1036
+ key: "SEED_STAFF_EMAIL",
1037
+ schema: "z.email().default(\"staff@example.com\")",
1038
+ example: "staff@example.com",
1039
+ comment: "Development staff account created by `db:seed` (never in production)"
1040
+ },
1041
+ {
1042
+ key: "SEED_STAFF_PASSWORD",
1043
+ schema: "z.string().min(10).optional()",
1044
+ example: "",
1045
+ secret: true,
1046
+ comment: "Password for the development staff account (generated by tatara new)"
1047
+ }
1048
+ ],
1009
1049
  slots: [
1010
1050
  {
1011
1051
  file: "src/core/http/routes.ts",
@@ -1018,15 +1058,48 @@ const admin = {
1018
1058
  content: "\"apps/admin/server\": \"src/apps/admin/server.ts\","
1019
1059
  },
1020
1060
  {
1021
- file: "src/core/http/app.ts",
1061
+ file: "src/apps/http.ts",
1022
1062
  slot: "imports",
1023
- content: "import { requireRole } from \"@tatara/auth\";"
1063
+ content: [
1064
+ "import { requireRole } from \"@tatara/auth\";",
1065
+ "import { except } from \"hono/combine\";",
1066
+ "import { attachStaffPrincipal, staffAuth } from \"#modules/staff/services/auth\";"
1067
+ ].join("\n")
1068
+ },
1069
+ {
1070
+ file: "src/apps/http.ts",
1071
+ slot: "middleware",
1072
+ order: 50,
1073
+ content: "app.use(name === \"admin\" ? attachStaffPrincipal : attachPrincipal);"
1024
1074
  },
1025
1075
  {
1026
- file: "src/core/http/app.ts",
1076
+ file: "src/apps/http.ts",
1077
+ slot: "routes",
1078
+ order: 2,
1079
+ content: "if (name === \"admin\") app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) => staffAuth.handler(c.req.raw));"
1080
+ },
1081
+ {
1082
+ file: "src/apps/http.ts",
1027
1083
  slot: "routes",
1028
1084
  order: 5,
1029
- content: "if (name === \"admin\") app.use(`${API_PREFIX}/*`, requireRole(\"admin\"));"
1085
+ content: "if (name === \"admin\") app.use(`${API_PREFIX}/*`, except(`${API_PREFIX}/auth/*`, requireRole(\"staff\")));"
1086
+ },
1087
+ {
1088
+ file: "src/core/authz/system-roles.ts",
1089
+ slot: "roles",
1090
+ content: "{ key: \"staff\", name: \"Staff\", description: \"Internal staff: platform permissions on the admin app.\" },"
1091
+ },
1092
+ {
1093
+ file: "src/modules/staff/services/auth.ts",
1094
+ slot: "resolvers",
1095
+ when: ["auth-keycloak"],
1096
+ content: "() => (c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
1097
+ },
1098
+ {
1099
+ file: "src/modules/staff/services/auth.ts",
1100
+ slot: "imports",
1101
+ when: ["auth-keycloak"],
1102
+ content: "import { keycloakPrincipal } from \"@tatara/keycloak\";\nimport { auth } from \"#modules/users/services/auth\";"
1030
1103
  },
1031
1104
  {
1032
1105
  file: "tests/helpers/api.ts",
@@ -1041,11 +1114,15 @@ const admin = {
1041
1114
  {
1042
1115
  file: "AGENTS.md",
1043
1116
  slot: "sections",
1044
- content: `## Admin app
1117
+ content: `## Admin app and staff
1045
1118
 
1046
1119
  - \`apps/admin\` is a second HTTP app on \`ADMIN_PORT\`. Routes with \`defineRoutes({ app: "admin", … })\`
1047
- are served only there, and every \`/api\` route on it requires the \`admin\` role.
1048
- - Test it with \`adminApi\` from \`#tests/helpers/api\`.
1120
+ are served only there, and every \`/api\` route on it needs a staff principal (role \`staff\`).
1121
+ - Staff are separate accounts (\`src/modules/staff\`, tables \`staff_*\`), with their own better-auth
1122
+ mounted at \`/api/auth\` on the admin app. They never share a table or a session with users.
1123
+ There's no staff sign-up; the dev account comes from \`SEED_STAFF_EMAIL\`/\`SEED_STAFF_PASSWORD\`.
1124
+ - Staff act in the \`platform\` org, so grant platform permissions to the built-in \`staff\` role.
1125
+ - Test it with \`adminApi\` from \`#tests/helpers/api\` and \`asStaff()\` from \`#tests/helpers/staff\`.
1049
1126
  `
1050
1127
  }
1051
1128
  ]
@@ -1228,23 +1305,23 @@ const i18n = {
1228
1305
  }],
1229
1306
  slots: [
1230
1307
  {
1231
- file: "src/core/http/app.ts",
1308
+ file: "src/apps/http.ts",
1232
1309
  slot: "imports",
1233
1310
  content: "import { detectLanguage } from \"#core/i18n/index\";"
1234
1311
  },
1235
1312
  {
1236
- file: "src/core/http/app.ts",
1313
+ file: "src/apps/http.ts",
1237
1314
  slot: "middleware",
1238
1315
  order: 10,
1239
1316
  content: "app.use(detectLanguage);"
1240
1317
  },
1241
1318
  {
1242
- file: "src/core/http/app.ts",
1319
+ file: "src/apps/http.ts",
1243
1320
  slot: "imports",
1244
1321
  content: "import { t } from \"#core/i18n/index\";"
1245
1322
  },
1246
1323
  {
1247
- file: "src/core/http/app.ts",
1324
+ file: "src/apps/http.ts",
1248
1325
  slot: "error-options",
1249
1326
  content: ["message: (err, c) =>", " err.translatable ? t(c.get(\"language\"), `errors.${err.code}`, err.message) : err.message,"].join("\n")
1250
1327
  },
@@ -1268,18 +1345,19 @@ const auth = {
1268
1345
  dependencies: ["@tatara/auth"],
1269
1346
  slots: [
1270
1347
  {
1271
- file: "src/core/http/app.ts",
1348
+ file: "src/apps/http.ts",
1272
1349
  slot: "imports",
1273
- content: "import { attachPrincipal } from \"#core/auth/principal\";"
1350
+ content: "import { attachPrincipal } from \"#modules/users/services/auth\";"
1274
1351
  },
1275
1352
  {
1276
- file: "src/core/http/app.ts",
1353
+ file: "src/apps/http.ts",
1277
1354
  slot: "middleware",
1278
1355
  order: 50,
1356
+ unless: ["admin"],
1279
1357
  content: "app.use(attachPrincipal);"
1280
1358
  },
1281
1359
  {
1282
- file: "src/core/http/app.ts",
1360
+ file: "src/apps/http.ts",
1283
1361
  slot: "routes",
1284
1362
  order: 0,
1285
1363
  content: [
@@ -1310,7 +1388,7 @@ const BETTER_AUTH_AGENTS = `### better-auth
1310
1388
  - Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
1311
1389
  - Outside production, every email and SMS is kept in an in-memory outbox:
1312
1390
  \`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
1313
- - To send real SMS, implement \`SmsSender\` (from \`@tatara/auth\`) in \`src/core/auth/sms.ts\`.
1391
+ - To send real SMS, implement \`SmsSender\` (from \`@tatara/auth\`) in \`src/modules/users/services/sms.ts\`.
1314
1392
  `;
1315
1393
  const drizzlePg = ["drizzle", "postgres"];
1316
1394
  const drizzleMy = ["drizzle", "mysql"];
@@ -1343,36 +1421,48 @@ const betterAuth = {
1343
1421
  ],
1344
1422
  slots: [
1345
1423
  {
1346
- file: "src/core/http/app.ts",
1424
+ file: "src/apps/http.ts",
1347
1425
  slot: "imports",
1348
- content: "import { auth } from \"#core/auth/auth\";"
1426
+ content: "import { auth } from \"#modules/users/services/auth\";"
1349
1427
  },
1350
1428
  {
1351
- file: "src/core/http/app.ts",
1429
+ file: "src/apps/http.ts",
1352
1430
  slot: "imports",
1353
- content: "import { outbox } from \"#core/auth/delivery\";"
1431
+ content: "import { outbox } from \"#modules/users/services/delivery\";"
1354
1432
  },
1355
1433
  {
1356
- file: "src/core/http/app.ts",
1434
+ file: "src/apps/http.ts",
1357
1435
  slot: "imports",
1358
1436
  content: "import { mergeAuthOpenApi } from \"@tatara/auth\";"
1359
1437
  },
1360
1438
  {
1361
- file: "src/core/http/app.ts",
1439
+ file: "src/apps/http.ts",
1362
1440
  slot: "expose-headers",
1363
1441
  content: "\"set-auth-token\","
1364
1442
  },
1365
1443
  {
1366
- file: "src/core/http/app.ts",
1444
+ file: "src/apps/http.ts",
1445
+ slot: "openapi-options",
1446
+ unless: ["admin"],
1447
+ content: [
1448
+ "async extend(document) {",
1449
+ " if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
1450
+ "},"
1451
+ ].join("\n")
1452
+ },
1453
+ {
1454
+ file: "src/apps/http.ts",
1367
1455
  slot: "openapi-options",
1456
+ when: ["admin"],
1368
1457
  content: [
1369
1458
  "async extend(document) {",
1370
1459
  " if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
1460
+ " if (name === \"admin\") await mergeAuthOpenApi(document, staffAuth, `${API_PREFIX}/auth`);",
1371
1461
  "},"
1372
1462
  ].join("\n")
1373
1463
  },
1374
1464
  {
1375
- file: "src/core/http/app.ts",
1465
+ file: "src/apps/http.ts",
1376
1466
  slot: "routes",
1377
1467
  order: 1,
1378
1468
  content: [
@@ -1385,29 +1475,35 @@ const betterAuth = {
1385
1475
  ].join("\n")
1386
1476
  },
1387
1477
  {
1388
- file: "src/core/auth/delivery.ts",
1478
+ file: "src/modules/users/services/delivery.ts",
1389
1479
  slot: "email-delivery",
1390
1480
  unless: ["email"],
1391
1481
  content: "logger.info({ to: message.to, subject: message.subject }, \"email not sent: no email provider configured\");"
1392
1482
  },
1393
1483
  {
1394
- file: "src/core/auth/delivery.ts",
1484
+ file: "src/modules/users/services/delivery.ts",
1395
1485
  slot: "imports",
1396
1486
  unless: ["email"],
1397
1487
  content: "import { logger } from \"#core/logging/logger\";"
1398
1488
  },
1399
1489
  {
1400
- file: "src/core/auth/delivery.ts",
1490
+ file: "src/modules/users/services/delivery.ts",
1401
1491
  slot: "imports",
1402
1492
  when: ["email"],
1403
1493
  content: "import { sendEmail } from \"#core/email/send\";"
1404
1494
  },
1405
1495
  {
1406
- file: "src/core/auth/delivery.ts",
1496
+ file: "src/modules/users/services/delivery.ts",
1407
1497
  slot: "email-delivery",
1408
1498
  when: ["email"],
1409
1499
  content: "await sendEmail({ to: message.to, subject: message.subject, text: message.text });"
1410
1500
  },
1501
+ {
1502
+ file: "tests/helpers/auth.ts",
1503
+ slot: "authenticated",
1504
+ unless: ["auth-orgs"],
1505
+ content: "return { userId, headers };"
1506
+ },
1411
1507
  {
1412
1508
  file: "AGENTS.md",
1413
1509
  slot: "sections",
@@ -1421,17 +1517,17 @@ const authEmailOtp = {
1421
1517
  label: "Email OTP",
1422
1518
  slots: [
1423
1519
  {
1424
- file: "src/core/auth/auth.ts",
1520
+ file: "src/modules/users/services/auth.ts",
1425
1521
  slot: "imports",
1426
- content: "import { deliverEmail } from \"#core/auth/delivery\";"
1522
+ content: "import { deliverEmail } from \"#modules/users/services/delivery\";"
1427
1523
  },
1428
1524
  {
1429
- file: "src/core/auth/auth.ts",
1525
+ file: "src/modules/users/services/auth.ts",
1430
1526
  slot: "imports",
1431
1527
  content: "import { emailOTP } from \"better-auth/plugins\";"
1432
1528
  },
1433
1529
  {
1434
- file: "src/core/auth/auth.ts",
1530
+ file: "src/modules/users/services/auth.ts",
1435
1531
  slot: "plugins",
1436
1532
  content: [
1437
1533
  "emailOTP({",
@@ -1450,13 +1546,13 @@ const authEmailOtp = {
1450
1546
  };
1451
1547
  const phoneFields = [
1452
1548
  {
1453
- file: "src/core/auth/db/auth.schema.ts",
1549
+ file: "src/modules/users/db/auth.schema.ts",
1454
1550
  slot: "user-fields",
1455
1551
  when: drizzlePg,
1456
1552
  content: "phoneNumber: text().unique(),\nphoneNumberVerified: boolean(),"
1457
1553
  },
1458
1554
  {
1459
- file: "src/core/auth/db/auth.schema.ts",
1555
+ file: "src/modules/users/db/auth.schema.ts",
1460
1556
  slot: "user-fields",
1461
1557
  when: drizzleMy,
1462
1558
  content: "phoneNumber: varchar({ length: 32 }).unique(),\nphoneNumberVerified: boolean(),"
@@ -1473,17 +1569,17 @@ const authPhoneOtp = {
1473
1569
  label: "Phone OTP",
1474
1570
  slots: [
1475
1571
  {
1476
- file: "src/core/auth/auth.ts",
1572
+ file: "src/modules/users/services/auth.ts",
1477
1573
  slot: "imports",
1478
- content: "import { deliverSms } from \"#core/auth/delivery\";"
1574
+ content: "import { deliverSms } from \"#modules/users/services/delivery\";"
1479
1575
  },
1480
1576
  {
1481
- file: "src/core/auth/auth.ts",
1577
+ file: "src/modules/users/services/auth.ts",
1482
1578
  slot: "imports",
1483
1579
  content: "import { phoneNumber } from \"better-auth/plugins\";"
1484
1580
  },
1485
1581
  {
1486
- file: "src/core/auth/auth.ts",
1582
+ file: "src/modules/users/services/auth.ts",
1487
1583
  slot: "plugins",
1488
1584
  content: [
1489
1585
  "phoneNumber({",
@@ -1513,12 +1609,12 @@ const authPassword = {
1513
1609
  }],
1514
1610
  slots: [
1515
1611
  {
1516
- file: "src/core/auth/auth.ts",
1612
+ file: "src/modules/users/services/auth.ts",
1517
1613
  slot: "imports",
1518
- content: "import { deliverEmail } from \"#core/auth/delivery\";"
1614
+ content: "import { deliverEmail } from \"#modules/users/services/delivery\";"
1519
1615
  },
1520
1616
  {
1521
- file: "src/core/auth/auth.ts",
1617
+ file: "src/modules/users/services/auth.ts",
1522
1618
  slot: "options",
1523
1619
  content: [
1524
1620
  "emailAndPassword: {",
@@ -1531,7 +1627,7 @@ const authPassword = {
1531
1627
  ].join("\n")
1532
1628
  },
1533
1629
  {
1534
- file: "src/core/auth/db/seed.ts",
1630
+ file: "src/modules/users/db/seed.ts",
1535
1631
  slot: "seed-after-user",
1536
1632
  content: [
1537
1633
  "if (env.SEED_ADMIN_PASSWORD) {",
@@ -1548,25 +1644,25 @@ const authPassword = {
1548
1644
  };
1549
1645
  const adminFields = [
1550
1646
  {
1551
- file: "src/core/auth/db/auth.schema.ts",
1647
+ file: "src/modules/users/db/auth.schema.ts",
1552
1648
  slot: "user-fields",
1553
1649
  when: drizzlePg,
1554
1650
  content: "role: text(),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ withTimezone: true, mode: \"date\" }),"
1555
1651
  },
1556
1652
  {
1557
- file: "src/core/auth/db/auth.schema.ts",
1653
+ file: "src/modules/users/db/auth.schema.ts",
1558
1654
  slot: "user-fields",
1559
1655
  when: drizzleMy,
1560
1656
  content: "role: varchar({ length: 255 }),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ fsp: 3, mode: \"date\" }),"
1561
1657
  },
1562
1658
  {
1563
- file: "src/core/auth/db/auth.schema.ts",
1659
+ file: "src/modules/users/db/auth.schema.ts",
1564
1660
  slot: "session-fields",
1565
1661
  when: drizzlePg,
1566
1662
  content: "activeOrganizationId: text(),\nimpersonatedBy: text(),"
1567
1663
  },
1568
1664
  {
1569
- file: "src/core/auth/db/auth.schema.ts",
1665
+ file: "src/modules/users/db/auth.schema.ts",
1570
1666
  slot: "session-fields",
1571
1667
  when: drizzleMy,
1572
1668
  content: "activeOrganizationId: varchar({ length: 36 }),\nimpersonatedBy: varchar({ length: 36 }),"
@@ -1592,57 +1688,70 @@ const adminFields = [
1592
1688
  content: ["activeOrganizationId String? @map(\"active_organization_id\") @db.VarChar(36)", "impersonatedBy String? @map(\"impersonated_by\") @db.VarChar(36)"].join("\n")
1593
1689
  },
1594
1690
  {
1595
- file: "src/core/auth/adapter.ts",
1691
+ file: "src/modules/users/services/adapter.ts",
1596
1692
  slot: "imports",
1597
1693
  when: ["drizzle"],
1598
- content: "import * as orgSchema from \"#core/auth/db/organization.schema\";"
1694
+ content: "import * as orgSchema from \"#modules/users/db/organization.schema\";"
1599
1695
  },
1600
1696
  {
1601
- file: "src/core/auth/adapter.ts",
1697
+ file: "src/modules/users/services/adapter.ts",
1602
1698
  slot: "adapter-schema",
1603
1699
  when: ["drizzle"],
1604
1700
  content: "organization: orgSchema.organizations,\nmember: orgSchema.members,\ninvitation: orgSchema.invitations,"
1605
1701
  }
1606
1702
  ];
1703
+ const JOIN_TEST_ORG = [
1704
+ "/** Makes the user a member of `orgId` (a shared test org by default), creating it on first use. */",
1705
+ "export async function joinTestOrg(userId: string, orgId = \"test-org\", role = \"member\"): Promise<string> {",
1706
+ " const { adapter } = await auth.$context;",
1707
+ " const org = await adapter.findOne({ model: \"organization\", where: [{ field: \"id\", value: orgId }] });",
1708
+ " if (!org) {",
1709
+ " await adapter.create({",
1710
+ " model: \"organization\",",
1711
+ " data: { id: orgId, name: orgId, slug: orgId, createdAt: new Date() },",
1712
+ " forceAllowId: true,",
1713
+ " });",
1714
+ " }",
1715
+ " await adapter.create({",
1716
+ " model: \"member\",",
1717
+ " data: { organizationId: orgId, userId, role, createdAt: new Date() },",
1718
+ " });",
1719
+ " return orgId;",
1720
+ "}"
1721
+ ].join("\n");
1607
1722
  const authOrgs = {
1608
1723
  id: "auth-orgs",
1609
1724
  kind: "auth-method",
1610
1725
  label: "Organizations + roles",
1611
1726
  slots: [
1612
1727
  {
1613
- file: "src/core/auth/auth.ts",
1728
+ file: "src/modules/users/services/auth.ts",
1614
1729
  slot: "imports",
1615
1730
  content: "import { admin, organization } from \"better-auth/plugins\";"
1616
1731
  },
1617
1732
  {
1618
- file: "src/core/auth/auth.ts",
1733
+ file: "src/modules/users/services/auth.ts",
1619
1734
  slot: "plugins",
1620
1735
  unless: ["authz"],
1621
1736
  content: "organization(),\nadmin(),",
1622
1737
  order: 900
1623
1738
  },
1624
1739
  {
1625
- file: "src/core/auth/auth.ts",
1740
+ file: "src/modules/users/services/auth.ts",
1626
1741
  slot: "plugins",
1627
1742
  when: ["authz"],
1628
1743
  content: [
1629
- "organization({",
1630
- " organizationHooks: {",
1631
- " // authz rows outlive better-auth's member row; without this, re-adding restores old roles",
1632
- " afterRemoveMember: async ({ member }) => {",
1633
- " await removeMemberAccess(member.organizationId, member.userId);",
1634
- " },",
1635
- " },",
1636
- "}),",
1744
+ "// a removed member's authz roles and overlays go with the membership",
1745
+ "organization({ organizationHooks: authzOrganizationHooks(authz) }),",
1637
1746
  "admin(),"
1638
1747
  ].join("\n"),
1639
1748
  order: 900
1640
1749
  },
1641
1750
  {
1642
- file: "src/core/auth/auth.ts",
1751
+ file: "src/modules/users/services/auth.ts",
1643
1752
  slot: "imports",
1644
1753
  when: ["authz"],
1645
- content: "import { removeMemberAccess } from \"#core/authz/index\";"
1754
+ content: "import { authzOrganizationHooks } from \"@tatara/authz\";\nimport { authz } from \"#core/authz/index\";"
1646
1755
  },
1647
1756
  {
1648
1757
  file: "tests/helpers/auth.ts",
@@ -1650,7 +1759,22 @@ const authOrgs = {
1650
1759
  content: "...(options.roles ? { role: options.roles.join(\",\") } : {}),"
1651
1760
  },
1652
1761
  {
1653
- file: "src/core/auth/db/seed.ts",
1762
+ file: "tests/helpers/auth.ts",
1763
+ slot: "options",
1764
+ content: "/** the org the user joins and acts in (default: a shared test org) */\norgId?: string;"
1765
+ },
1766
+ {
1767
+ file: "tests/helpers/auth.ts",
1768
+ slot: "authenticated",
1769
+ content: ["const orgId = await joinTestOrg(userId, options.orgId);", "return { userId, orgId, headers: { ...headers, \"x-org-id\": orgId } };"].join("\n")
1770
+ },
1771
+ {
1772
+ file: "tests/helpers/auth.ts",
1773
+ slot: "helpers",
1774
+ content: JOIN_TEST_ORG
1775
+ },
1776
+ {
1777
+ file: "src/modules/users/db/seed.ts",
1654
1778
  slot: "seed-user-fields",
1655
1779
  content: "role: \"admin\","
1656
1780
  },
@@ -1708,41 +1832,41 @@ const authKeycloak = {
1708
1832
  } },
1709
1833
  slots: [
1710
1834
  {
1711
- file: "src/core/auth/auth.ts",
1835
+ file: "src/modules/users/services/auth.ts",
1712
1836
  slot: "imports",
1713
1837
  content: "import { keycloakTokens } from \"@tatara/keycloak\";"
1714
1838
  },
1715
1839
  {
1716
- file: "src/core/auth/auth.ts",
1840
+ file: "src/modules/users/services/auth.ts",
1717
1841
  slot: "imports",
1718
- content: "import { keycloak } from \"#core/auth/keycloak\";"
1842
+ content: "import { keycloak } from \"#modules/users/services/keycloak\";"
1719
1843
  },
1720
1844
  {
1721
- file: "src/core/auth/auth.ts",
1845
+ file: "src/modules/users/services/auth.ts",
1722
1846
  slot: "imports",
1723
1847
  content: "import { logger } from \"#core/logging/logger\";"
1724
1848
  },
1725
1849
  {
1726
- file: "src/core/auth/auth.ts",
1850
+ file: "src/modules/users/services/auth.ts",
1727
1851
  slot: "plugins",
1728
1852
  unless: ["auth-orgs"],
1729
1853
  content: "keycloakTokens({ verifier: keycloak, logger }),",
1730
1854
  order: 800
1731
1855
  },
1732
1856
  {
1733
- file: "src/core/auth/auth.ts",
1857
+ file: "src/modules/users/services/auth.ts",
1734
1858
  slot: "plugins",
1735
1859
  when: ["auth-orgs"],
1736
1860
  content: "keycloakTokens({ verifier: keycloak, logger, organizations: true }),",
1737
1861
  order: 800
1738
1862
  },
1739
1863
  {
1740
- file: "src/core/auth/principal.ts",
1864
+ file: "src/modules/users/services/auth.ts",
1741
1865
  slot: "resolvers",
1742
- content: "(c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
1866
+ content: "(instance) => (c) => keycloakPrincipal(instance, c.req.header(\"authorization\")),"
1743
1867
  },
1744
1868
  {
1745
- file: "src/core/auth/principal.ts",
1869
+ file: "src/modules/users/services/auth.ts",
1746
1870
  slot: "imports",
1747
1871
  content: "import { keycloakPrincipal } from \"@tatara/keycloak\";"
1748
1872
  },
@@ -1754,7 +1878,7 @@ const authKeycloak = {
1754
1878
  {
1755
1879
  file: "tests/helpers/auth.ts",
1756
1880
  slot: "imports",
1757
- content: "import { keycloak } from \"#core/auth/keycloak\";"
1881
+ content: "import { keycloak } from \"#modules/users/services/keycloak\";"
1758
1882
  },
1759
1883
  {
1760
1884
  file: "tests/helpers/auth.ts",
@@ -2213,7 +2337,6 @@ function validateOptions(input) {
2213
2337
  for (const addon of options.addons) {
2214
2338
  if ((addon === "authz" || addon === "admin") && options.auth === "none") throw new OptionsError(`The ${addon} add-on needs an auth provider.`);
2215
2339
  if (addon === "authz" && !hasDb) throw new OptionsError("The authz add-on needs a database.");
2216
- 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).");
2217
2340
  }
2218
2341
  return options;
2219
2342
  }
@@ -2438,7 +2561,7 @@ function pnpmWorkspace(features) {
2438
2561
  }
2439
2562
  /** Tests of tatara's wiring, not of the app; generated apps leave them out. */
2440
2563
  function isFrameworkTest(path) {
2441
- return /^src\/core\/.+\/tests\/[^/]+\.test\.ts$/.test(path);
2564
+ return path.startsWith("tests/tatara/") || /^src\/core\/.+\/tests\/[^/]+\.test\.ts$/.test(path);
2442
2565
  }
2443
2566
  /** Folds applicable variants into each feature so later steps see one flat definition. */
2444
2567
  function effectiveFeatures(options) {
@@ -2648,7 +2771,12 @@ function guardSlots(project, n) {
2648
2771
  });
2649
2772
  if (project.has("authz")) {
2650
2773
  add("imports", "import { requirePermission } from \"#core/authz/index\";");
2651
- for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
2774
+ const orgs = project.has("auth-orgs");
2775
+ if (orgs) add("imports", "import { requireOrg } from \"#modules/users/services/auth\";");
2776
+ for (const [route, action] of Object.entries(ACTIONS)) {
2777
+ const guards = `${orgs ? "requireOrg(), " : ""}requirePermission("${n.kebab}:${action}")`;
2778
+ add(`guard-${route}`, `middleware: [${guards}] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
2779
+ }
2652
2780
  add("auth-errors", "...errorResponses(401, 403),");
2653
2781
  } else if (project.has("auth")) {
2654
2782
  add("imports", "import { requireAuth } from \"@tatara/auth\";");
@@ -3451,7 +3579,7 @@ function importsOf(text, specifier) {
3451
3579
  }
3452
3580
  //#endregion
3453
3581
  //#region src/upgrade/codemods/groups.ts
3454
- async function read$4(ctx, path) {
3582
+ async function read$5(ctx, path) {
3455
3583
  return ctx.read(path).catch(() => void 0);
3456
3584
  }
3457
3585
  function sourceFiles(ctx) {
@@ -3464,7 +3592,7 @@ function sourceFiles(ctx) {
3464
3592
  async function blockersOf$1(ctx, group, sources) {
3465
3593
  const blockers = [];
3466
3594
  for (const path of [...group.regenerate, ...group.remove]) {
3467
- const text = await read$4(ctx, path);
3595
+ const text = await read$5(ctx, path);
3468
3596
  if (text === void 0) continue;
3469
3597
  if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3470
3598
  }
@@ -3604,6 +3732,140 @@ const addonPackages = {
3604
3732
  }
3605
3733
  };
3606
3734
  //#endregion
3735
+ //#region src/upgrade/moves.ts
3736
+ /**
3737
+ * Files later CLIs generate somewhere else. An older codemod that regenerates an old path gets
3738
+ * what this CLI generates at the new one; the codemod of the release that moved it moves it.
3739
+ */
3740
+ const MOVED_IN_0_10 = {
3741
+ "src/core/http/app.ts": "src/apps/http.ts",
3742
+ "src/core/auth/auth.ts": "src/modules/users/services/auth.ts",
3743
+ "src/core/auth/adapter.ts": "src/modules/users/services/adapter.ts",
3744
+ "src/core/auth/delivery.ts": "src/modules/users/services/delivery.ts",
3745
+ "src/core/auth/sms.ts": "src/modules/users/services/sms.ts",
3746
+ "src/core/auth/keycloak.ts": "src/modules/users/services/keycloak.ts",
3747
+ "src/core/auth/db/seed.ts": "src/modules/users/db/seed.ts",
3748
+ "src/core/auth/db/auth.schema.ts": "src/modules/users/db/auth.schema.ts",
3749
+ "src/core/auth/db/organization.schema.ts": "src/modules/users/db/organization.schema.ts",
3750
+ "src/core/auth/routes/me.routes.ts": "src/modules/users/routes/me.routes.ts",
3751
+ "src/core/auth/commands/keycloak-token.ts": "src/modules/users/commands/keycloak-token.ts",
3752
+ "src/core/authz/routes/authorization.routes.ts": "src/modules/users/routes/authorization.routes.ts"
3753
+ };
3754
+ /** Merged into another file in 0.10: nothing is generated in its place. */
3755
+ const MERGED_IN_0_10 = { "src/core/auth/principal.ts": "src/modules/users/services/auth.ts" };
3756
+ /** Where this CLI generates what used to be at `path` (the path itself if it never moved). */
3757
+ function currentPath(path) {
3758
+ return MOVED_IN_0_10[path] ?? path;
3759
+ }
3760
+ //#endregion
3761
+ //#region src/upgrade/codemods/app-owned-auth.ts
3762
+ /** Import specifiers that moved, old → new; every name keeps working at the new place. */
3763
+ const SPECIFIERS = {
3764
+ "#core/http/app": "#apps/http",
3765
+ "#core/auth/auth": "#modules/users/services/auth",
3766
+ "#core/auth/principal": "#modules/users/services/auth",
3767
+ "#core/auth/adapter": "#modules/users/services/adapter",
3768
+ "#core/auth/delivery": "#modules/users/services/delivery",
3769
+ "#core/auth/sms": "#modules/users/services/sms",
3770
+ "#core/auth/keycloak": "#modules/users/services/keycloak",
3771
+ "#core/auth/db/auth.schema": "#modules/users/db/auth.schema",
3772
+ "#core/auth/db/organization.schema": "#modules/users/db/organization.schema"
3773
+ };
3774
+ /** Files whose content this release changes in place (new imports, orgRole, single tenant). */
3775
+ const REGENERATED$2 = [
3776
+ "src/core/http/context.ts",
3777
+ "tests/helpers/auth.ts",
3778
+ "src/core/authz/index.ts"
3779
+ ];
3780
+ async function read$4(ctx, path) {
3781
+ return ctx.read(path).catch(() => void 0);
3782
+ }
3783
+ /** Inserts the single-tenant option into an app's own createAuthz call, if it lacks one. */
3784
+ function withSingleTenant(text) {
3785
+ if (text.includes("singleTenant")) return text;
3786
+ return text.replace(/(createAuthz(?:<[^>]*>)?\(\{\n)/, "$1 singleTenant: \"default\",\n");
3787
+ }
3788
+ const STAFF_FILES = [
3789
+ "src/modules/staff/services/auth.ts",
3790
+ "src/modules/staff/services/adapter.ts",
3791
+ "src/modules/staff/db/seed.ts",
3792
+ "src/modules/staff/db/staff.schema.ts",
3793
+ "prisma/models/staff.prisma",
3794
+ "tests/helpers/staff.ts"
3795
+ ];
3796
+ /** The admin app now signs in staff accounts of its own; the regenerated apps/http.ts imports them. */
3797
+ async function addStaff(ctx) {
3798
+ for (const path of STAFF_FILES) {
3799
+ if (await read$4(ctx, path) !== void 0) continue;
3800
+ const text = await ctx.renderCurrent(path);
3801
+ if (text !== void 0) await ctx.write(path, text);
3802
+ }
3803
+ const customisedEnv = [];
3804
+ for (const path of [
3805
+ "src/core/config/env.ts",
3806
+ ".env.example",
3807
+ ".env.test"
3808
+ ]) {
3809
+ const text = await read$4(ctx, path);
3810
+ if (text === void 0) continue;
3811
+ if (await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
3812
+ else customisedEnv.push(path);
3813
+ }
3814
+ if (customisedEnv.length > 0) ctx.report.notes.push(`Add ADMIN_URL, SEED_STAFF_EMAIL and SEED_STAFF_PASSWORD to ${customisedEnv.join(", ")} (they are customised, so they were left as they are).`);
3815
+ ctx.report.notes.push("The admin app now signs in staff accounts (src/modules/staff, tables staff_*) instead of users with the admin role. Run `tatara db generate` for the new tables, add { key: \"staff\" } to src/core/authz/system-roles.ts if you use authz, and grant your platform permissions to it.");
3816
+ }
3817
+ const appOwnedAuth = {
3818
+ version: "0.10.0",
3819
+ id: "app-owned-auth",
3820
+ description: "Move auth into src/modules/users and HTTP wiring into src/apps; explicit org context",
3821
+ async run(ctx) {
3822
+ const { config } = ctx.project;
3823
+ const orgs = config.authMethods.includes("orgs");
3824
+ if (config.addons.includes("authz") && !orgs) {
3825
+ const index = await read$4(ctx, "src/core/authz/index.ts");
3826
+ const next = index && withSingleTenant(index);
3827
+ if (next && next !== index) await ctx.write("src/core/authz/index.ts", next);
3828
+ }
3829
+ const blockers = [];
3830
+ const moves = [];
3831
+ for (const [from, to] of Object.entries(MOVED_IN_0_10)) {
3832
+ const text = await read$4(ctx, from);
3833
+ if (text === void 0) continue;
3834
+ const there = await read$4(ctx, to);
3835
+ if (there !== void 0 && there !== await ctx.renderCurrent(to)) blockers.push(`${to} already exists`);
3836
+ else if (await ctx.baseline.isUnchanged(from, text)) moves.push([from, to]);
3837
+ else blockers.push(`${from} is customised`);
3838
+ }
3839
+ const merged = [];
3840
+ for (const from of Object.keys(MERGED_IN_0_10)) {
3841
+ const text = await read$4(ctx, from);
3842
+ if (text === void 0) continue;
3843
+ if (await ctx.baseline.isUnchanged(from, text)) merged.push(from);
3844
+ else blockers.push(`${from} is customised`);
3845
+ }
3846
+ if (blockers.length > 0) {
3847
+ ctx.report.notes.push(`Auth was not moved into src/modules/users: ${blockers.join("; ")}. Compare with a newly generated app (auth in src/modules/users/services/auth.ts built with createAuthInstance, HTTP wiring in src/apps/http.ts) and move it by hand.`);
3848
+ ctx.report.blocked.push("app-owned-auth");
3849
+ return;
3850
+ }
3851
+ for (const [from, to] of moves) {
3852
+ const next = await ctx.renderCurrent(to);
3853
+ if (next !== void 0) await ctx.write(to, next);
3854
+ await ctx.removeIfUnchanged(from);
3855
+ }
3856
+ for (const from of merged) await ctx.removeIfUnchanged(from);
3857
+ for (const path of REGENERATED$2) {
3858
+ const text = await read$4(ctx, path);
3859
+ if (text !== void 0 && await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
3860
+ }
3861
+ for (const [from, to] of Object.entries(SPECIFIERS)) await ctx.rewriteImports(from, to);
3862
+ if (config.addons.includes("admin")) await addStaff(ctx);
3863
+ const leftovers = await glob(["src/core/auth/**/*.ts"], { cwd: ctx.project.root });
3864
+ if (leftovers.length > 0) ctx.report.notes.push(`Move these into src/modules/users (core must not hold auth flows): ${leftovers.join(", ")}`);
3865
+ if (orgs) ctx.report.notes.push("Requests that act in an organization now name it (x-org-id header, or the session's active organization); authz no longer falls back to a default org.");
3866
+ }
3867
+ };
3868
+ //#endregion
3607
3869
  //#region src/upgrade/codemods/auth-package.ts
3608
3870
  const AUTH = "@tatara/auth";
3609
3871
  const KEYCLOAK = "@tatara/keycloak";
@@ -4238,12 +4500,17 @@ const CODEMODS = [
4238
4500
  await ctx.removeIfUnchanged(path);
4239
4501
  }
4240
4502
  }
4241
- }
4503
+ },
4504
+ appOwnedAuth
4242
4505
  ];
4243
4506
  //#endregion
4244
4507
  //#region src/upgrade/context.ts
4245
4508
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4246
- function createContext(project, baseline, opts = {}) {
4509
+ function createContext(project, appBaseline, opts = {}) {
4510
+ const ours = /* @__PURE__ */ new Map();
4511
+ const baseline = { async isUnchanged(path, content) {
4512
+ return ours.get(path) === hashContent(content) || appBaseline.isUnchanged(path, content);
4513
+ } };
4247
4514
  const report = {
4248
4515
  ran: [],
4249
4516
  written: [],
@@ -4263,6 +4530,7 @@ function createContext(project, baseline, opts = {}) {
4263
4530
  const before = existsSync(abs(path)) ? await readFile(abs(path), "utf8") : void 0;
4264
4531
  if (before === void 0 || await baseline.isUnchanged(path, before)) report.regenerated.push(path);
4265
4532
  }
4533
+ ours.set(path, hashContent(text));
4266
4534
  if (opts.dryRun) return;
4267
4535
  await mkdir(dirname(abs(path)), { recursive: true });
4268
4536
  await writeFile(abs(path), text);
@@ -4315,7 +4583,7 @@ function createContext(project, baseline, opts = {}) {
4315
4583
  addons,
4316
4584
  packageManager
4317
4585
  });
4318
- return (await rendered).get(path);
4586
+ return (await rendered).get(currentPath(path));
4319
4587
  },
4320
4588
  async regenerate(path) {
4321
4589
  const next = await ctx.renderCurrent(path);