@tatara/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/bin.mjs +172 -81
  2. package/package.json +1 -1
  3. package/templates/features/authz/files/src/core/authz/constants.ts +4 -0
  4. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +5 -5
  5. package/templates/features/authz/files/src/core/authz/services/members.ts +17 -1
  6. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  7. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  8. package/templates/features/authz/files/src/core/authz/types.ts +2 -0
  9. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +13 -0
  10. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +7 -0
  11. package/templates/features/base/files/AGENTS.md +2 -0
  12. package/templates/features/base/files/Dockerfile +1 -0
  13. package/templates/features/base/files/_github/workflows/ci.yml +10 -0
  14. package/templates/features/base/files/src/core/commands/define.ts +9 -1
  15. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  16. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  17. package/templates/features/base/files/src/core/http/app.ts +6 -1
  18. package/templates/features/base/files/src/core/http/errors.ts +40 -12
  19. package/templates/features/base/files/src/core/http/middleware/validation.ts +1 -1
  20. package/templates/features/base/files/src/core/http/response.ts +5 -0
  21. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  22. package/templates/features/better-auth/files/src/core/auth/principal.ts +18 -1
  23. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  24. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  25. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  26. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  27. package/templates/features/db/files/src/core/db/commands/reset.ts +2 -2
  28. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  29. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +2 -2
  30. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +3 -2
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. */
@@ -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,64 +1447,7 @@ 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
1453
  const keycloak = {
@@ -1389,7 +1465,7 @@ const keycloak = {
1389
1465
  },
1390
1466
  {
1391
1467
  key: "KEYCLOAK_URL",
1392
- schema: "z.url().default(\"http://localhost:8080\")",
1468
+ schema: "localDefault(z.url(), \"http://localhost:8080\")",
1393
1469
  example: "http://localhost:8080"
1394
1470
  },
1395
1471
  {
@@ -1509,13 +1585,13 @@ const base = {
1509
1585
  },
1510
1586
  {
1511
1587
  key: "APP_URL",
1512
- schema: "z.url().default(\"http://localhost:3000\")",
1588
+ schema: "localDefault(z.url(), \"http://localhost:3000\")",
1513
1589
  example: "http://localhost:3000",
1514
1590
  comment: "Public URL of the API"
1515
1591
  },
1516
1592
  {
1517
1593
  key: "CORS_ORIGINS",
1518
- schema: "z.string().default(\"http://localhost:5173\").transform((v) => v.split(\",\").map((o) => o.trim()).filter(Boolean))",
1594
+ schema: "localDefault(z.string(), \"http://localhost:5173\").transform((v) => v.split(\",\").map((o) => o.trim()).filter(Boolean))",
1519
1595
  example: "http://localhost:5173",
1520
1596
  comment: "Comma-separated browser origins allowed to call the API"
1521
1597
  },
@@ -1550,7 +1626,7 @@ const pnpm = {
1550
1626
  slot: "docker-build",
1551
1627
  content: [
1552
1628
  "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
1553
- "RUN pnpm install --frozen-lockfile",
1629
+ "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
1630
  "COPY . .",
1555
1631
  "RUN pnpm run build"
1556
1632
  ].join("\n")
@@ -1561,7 +1637,8 @@ const pnpm = {
1561
1637
  order: 0,
1562
1638
  content: [
1563
1639
  "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
1564
- "RUN pnpm install --frozen-lockfile --prod",
1640
+ "# cache mounts keep the package store out of the image",
1641
+ "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
1642
  "COPY --from=build /app/dist ./dist"
1566
1643
  ].join("\n")
1567
1644
  },
@@ -1589,7 +1666,7 @@ const npm = {
1589
1666
  slot: "docker-build",
1590
1667
  content: [
1591
1668
  "COPY package.json package-lock.json ./",
1592
- "RUN npm ci",
1669
+ "RUN --mount=type=cache,target=/root/.npm npm ci",
1593
1670
  "COPY . .",
1594
1671
  "RUN npm run build"
1595
1672
  ].join("\n")
@@ -1600,7 +1677,7 @@ const npm = {
1600
1677
  order: 0,
1601
1678
  content: [
1602
1679
  "COPY package.json package-lock.json ./",
1603
- "RUN npm ci --omit=dev",
1680
+ "RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev",
1604
1681
  "COPY --from=build /app/dist ./dist"
1605
1682
  ].join("\n")
1606
1683
  },
@@ -1632,7 +1709,7 @@ const bun = {
1632
1709
  slot: "docker-build",
1633
1710
  content: [
1634
1711
  "COPY package.json bun.lock ./",
1635
- "RUN bun install --frozen-lockfile",
1712
+ "RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile",
1636
1713
  "COPY . .",
1637
1714
  "RUN bun run build"
1638
1715
  ].join("\n")
@@ -1643,7 +1720,7 @@ const bun = {
1643
1720
  order: 0,
1644
1721
  content: [
1645
1722
  "COPY package.json bun.lock ./",
1646
- "RUN bun install --frozen-lockfile --production",
1723
+ "RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --production",
1647
1724
  "COPY --from=build /app/dist ./dist"
1648
1725
  ].join("\n")
1649
1726
  },
@@ -2002,6 +2079,13 @@ function envModule(vars) {
2002
2079
  return `import { createEnv } from "@t3-oss/env-core";
2003
2080
  import { z } from "zod";
2004
2081
 
2082
+ const production = process.env.NODE_ENV === "production";
2083
+
2084
+ /** A default for local development; production must set the value explicitly. */
2085
+ function localDefault<T extends z.ZodType>(schema: T, value: z.input<T>): T {
2086
+ return production ? schema : (schema.default(value as never) as unknown as T);
2087
+ }
2088
+
2005
2089
  /** The only place that reads process.env. Add new variables here and to .env.example. */
2006
2090
  export const env = createEnv({
2007
2091
  server: {
@@ -2370,6 +2454,13 @@ async function generate(project, request) {
2370
2454
  }
2371
2455
  const existing = [...rendered.keys()].filter((rel) => existsSync(join(project.root, rel)));
2372
2456
  if (existing.length > 0) throw new GeneratorError(`Refusing to overwrite existing file(s):\n ${existing.join("\n ")}`);
2457
+ const routeSources = /* @__PURE__ */ new Map();
2458
+ for (const rel of await glob("src/**/routes/*.routes.ts", { cwd: project.root })) routeSources.set(rel, await readFile(join(project.root, rel), "utf8"));
2459
+ const before = routeMountCollisions(routeSources).length;
2460
+ for (const [rel, text] of rendered) routeSources.set(rel, text);
2461
+ const after = routeMountCollisions(routeSources);
2462
+ const collision = after.length > before ? after.at(-1) : void 0;
2463
+ if (collision) throw new GeneratorError(`${collision.file}: ${collision.message}. Pick another --name, or create it and change basePath.`);
2373
2464
  const files = [...rendered.keys()].sort();
2374
2465
  if (request.dryRun) return {
2375
2466
  kind: request.kind,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,9 +15,13 @@ export const AUDIT_CHANGE_TYPES = [
15
15
  "member_role_revoked",
16
16
  "overlay_granted",
17
17
  "overlay_revoked",
18
+ "member_removed",
18
19
  ] as const;
19
20
  export type AuditChangeType = (typeof AUDIT_CHANGE_TYPES)[number];
20
21
 
22
+ /** Audit actor for changes made by the system rather than a user (e.g. org membership hooks). */
23
+ export const SYSTEM_ACTOR = "system";
24
+
21
25
  /** Org used when the principal has none (single-tenant apps, users without an active org). */
22
26
  export const DEFAULT_ORG_ID = "default";
23
27
 
@@ -19,7 +19,7 @@ import {
19
19
  userParams,
20
20
  userRoleParams,
21
21
  } from "#core/authz/validators";
22
- import { errorResponses, jsonContent, success, successSchema } from "#core/http/response";
22
+ import { errorResponses, jsonBody, jsonContent, success, successSchema } from "#core/http/response";
23
23
  import { createRouter, defineRoutes } from "#core/http/routes";
24
24
  import { catalog } from "#generated/permissions";
25
25
 
@@ -100,7 +100,7 @@ router.openapi(
100
100
  summary: "Create a custom role",
101
101
  middleware: [requirePermission("role:create")] as const,
102
102
  security: [{ bearerAuth: ["role:create"] }],
103
- request: { body: jsonContent(createRoleSchema, "The role") },
103
+ request: { body: jsonBody(createRoleSchema, "The role") },
104
104
  responses: {
105
105
  201: jsonContent(successSchema(roleSchema), "Created"),
106
106
  ...errorResponses(400, 409),
@@ -122,7 +122,7 @@ router.openapi(
122
122
  summary: "Update a custom role",
123
123
  middleware: [requirePermission("role:update")] as const,
124
124
  security: [{ bearerAuth: ["role:update"] }],
125
- request: { params: idParams, body: jsonContent(updateRoleSchema, "Fields to change") },
125
+ request: { params: idParams, body: jsonBody(updateRoleSchema, "Fields to change") },
126
126
  responses: {
127
127
  200: jsonContent(successSchema(roleSchema), "Updated"),
128
128
  ...errorResponses(400, 404),
@@ -179,7 +179,7 @@ router.openapi(
179
179
  summary: "Assign a role to a member",
180
180
  middleware: [requirePermission("member:update")] as const,
181
181
  security: [{ bearerAuth: ["member:update"] }],
182
- request: { params: userParams, body: jsonContent(z.object({ roleId: z.string() }), "Role") },
182
+ request: { params: userParams, body: jsonBody(z.object({ roleId: z.string() }), "Role") },
183
183
  responses: {
184
184
  200: jsonContent(successSchema(z.object({ assigned: z.boolean() })), "Assigned"),
185
185
  ...errorResponses(404),
@@ -225,7 +225,7 @@ router.openapi(
225
225
  summary: "Grant or deny one permission to a member",
226
226
  middleware: [requirePermission("member:update")] as const,
227
227
  security: [{ bearerAuth: ["member:update"] }],
228
- request: { params: userParams, body: jsonContent(overlayInputSchema, "Overlay") },
228
+ request: { params: userParams, body: jsonBody(overlayInputSchema, "Overlay") },
229
229
  responses: {
230
230
  201: jsonContent(successSchema(z.object({ id: z.string() })), "Created"),
231
231
  ...errorResponses(400),
@@ -1,5 +1,5 @@
1
1
  import { invalidateMember } from "#core/authz/cache";
2
- import type { OverlayEffect, Scope } from "#core/authz/constants";
2
+ import { type OverlayEffect, type Scope, SYSTEM_ACTOR } from "#core/authz/constants";
3
3
  import { store } from "#core/authz/db/store";
4
4
  import { audit } from "#core/authz/services/audit";
5
5
  import { validateGrants } from "#core/authz/services/grants";
@@ -31,6 +31,22 @@ export async function revokeRole(actorId: string, orgId: string, userId: string,
31
31
  await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_revoked", before: { roleId } });
32
32
  }
33
33
 
34
+ /** Removes all of a user's access in an org; called when they stop being a member. */
35
+ export async function removeMemberAccess(orgId: string, userId: string, actorId = SYSTEM_ACTOR) {
36
+ const removed = await store.removeMember(orgId, userId);
37
+ invalidateMember(userId, orgId);
38
+ if (removed.roles + removed.overlays > 0) {
39
+ await audit({
40
+ orgId,
41
+ actorId,
42
+ targetUserId: userId,
43
+ changeType: "member_removed",
44
+ before: removed,
45
+ });
46
+ }
47
+ return removed;
48
+ }
49
+
34
50
  export interface OverlayInput {
35
51
  resource: string;
36
52
  action: string;
@@ -0,0 +1,34 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+ import { asUser, authenticate } from "#tests/helpers/auth";
4
+ import { resetDatabase } from "#tests/helpers/db";
5
+
6
+ beforeEach(async () => {
7
+ await resetDatabase();
8
+ });
9
+
10
+ describe("error messages", () => {
11
+ it("keeps a specific message instead of the generic one for its code", async () => {
12
+ const headers = await asUser({ systemRoles: ["admin"] });
13
+ const role = await api.post<{ data: { id: string } }>(
14
+ "/api/authorization/roles",
15
+ { key: "auditor", name: "Auditor", grants: [] },
16
+ { headers },
17
+ );
18
+ const member = await authenticate();
19
+ await api.post(
20
+ `/api/authorization/members/${member.userId}/roles`,
21
+ { roleId: role.body.data.id },
22
+ { headers },
23
+ );
24
+ const res = await api.delete<{ code: string; error: string }>(
25
+ `/api/authorization/roles/${role.body.data.id}`,
26
+ { headers },
27
+ );
28
+ expect(res.status).toBe(409);
29
+ expect(res.body).toMatchObject({
30
+ code: "CONFLICT",
31
+ error: 'Role "auditor" is assigned to 1 member(s)',
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,27 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { app } from "#apps/api/app";
3
+ import { asUser } from "#tests/helpers/auth";
4
+ import { resetDatabase } from "#tests/helpers/db";
5
+
6
+ beforeEach(async () => {
7
+ await resetDatabase();
8
+ });
9
+
10
+ // A body sent without Content-Type used to skip validation and reach services as {}
11
+ describe("JSON request bodies", () => {
12
+ it("rejects a missing body with 400", async () => {
13
+ const headers = await asUser({ systemRoles: ["admin"] });
14
+ const res = await app.request("/api/authorization/roles", { method: "POST", headers });
15
+ expect(res.status).toBe(400);
16
+ expect(await res.json()).toMatchObject({ code: "VALIDATION_ERROR" });
17
+ });
18
+
19
+ it("does not assign a role from a missing body", async () => {
20
+ const headers = await asUser({ systemRoles: ["admin"] });
21
+ const res = await app.request("/api/authorization/members/someone/roles", {
22
+ method: "POST",
23
+ headers,
24
+ });
25
+ expect(res.status).toBe(400);
26
+ });
27
+ });
@@ -77,6 +77,8 @@ export interface AuthzStore {
77
77
  memberRoles(orgId: string, userId: string): Promise<RoleRow[]>;
78
78
  assignRole(orgId: string, userId: string, roleId: string, by: string | null): Promise<boolean>;
79
79
  revokeRole(orgId: string, userId: string, roleId: string): Promise<boolean>;
80
+ /** Deletes every role assignment and overlay the user has in the org. */
81
+ removeMember(orgId: string, userId: string): Promise<{ roles: number; overlays: number }>;
80
82
  listOverlays(orgId: string, userId: string): Promise<OverlayRow[]>;
81
83
  addOverlay(input: Omit<OverlayRow, "id" | "createdAt">): Promise<string>;
82
84
  removeOverlay(orgId: string, userId: string, overlayId: string): Promise<OverlayRow | null>;
@@ -194,6 +194,19 @@ export const store: AuthzStore = {
194
194
  return true;
195
195
  },
196
196
 
197
+ async removeMember(orgId, userId) {
198
+ const roleWhere = and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId));
199
+ const overlayWhere = and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId));
200
+ // count first: MySQL has no RETURNING
201
+ return db.transaction(async (tx) => {
202
+ const [roles] = await tx.select({ n: count() }).from(authzMemberRoles).where(roleWhere);
203
+ const [overlays] = await tx.select({ n: count() }).from(authzOverlays).where(overlayWhere);
204
+ await tx.delete(authzMemberRoles).where(roleWhere);
205
+ await tx.delete(authzOverlays).where(overlayWhere);
206
+ return { roles: roles?.n ?? 0, overlays: overlays?.n ?? 0 };
207
+ });
208
+ },
209
+
197
210
  async listOverlays(orgId, userId) {
198
211
  const rows = await db
199
212
  .select()
@@ -168,6 +168,13 @@ export const store: AuthzStore = {
168
168
  return count > 0;
169
169
  },
170
170
 
171
+ async removeMember(orgId, userId) {
172
+ const [roles, overlays] = await db.$transaction([
173
+ db.authzMemberRole.deleteMany({ where: { orgId, userId } }),
174
+ db.authzOverlay.deleteMany({ where: { orgId, userId } }),
175
+ ]);
176
+ return { roles: roles.count, overlays: overlays.count };
177
+ },
171
178
  async listOverlays(orgId, userId) {
172
179
  const rows = await db.authzOverlay.findMany({
173
180
  where: { orgId, userId },
@@ -45,6 +45,8 @@ __PM_EXEC__ tatara routes # every route and what protects it
45
45
  ## HTTP
46
46
 
47
47
  - Every route uses `createRoute` from `@hono/zod-openapi`, with request and response schemas.
48
+ Declare request bodies with `jsonBody(schema, description)` (required; a missing body is a 400),
49
+ responses with `jsonContent(...)`.
48
50
  The spec is served at `/openapi.json`, the reference UI at `/docs`.
49
51
  - Responses use the envelope: `success(data)`, `paginated(items, page, pageSize, total)`.
50
52
  Errors are thrown as `AppError` subclasses (`NotFoundError`, `ConflictError`,
@@ -1,3 +1,4 @@
1
+ # syntax=docker/dockerfile:1
1
2
  # One image for every entrypoint. Default: the API.
2
3
  # Other processes: override the command, e.g. `node dist/apps/worker/index.mjs`.
3
4
  FROM node:24-alpine AS base
@@ -5,6 +5,9 @@ on:
5
5
  branches: [main]
6
6
  pull_request:
7
7
 
8
+ permissions:
9
+ contents: read
10
+
8
11
  jobs:
9
12
  verify:
10
13
  runs-on: ubuntu-latest
@@ -14,3 +17,10 @@ jobs:
14
17
  - run: cp .env.example .env
15
18
  # tatara:slot ci-services
16
19
  - run: __PM_RUN__ verify
20
+
21
+ image:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v5
25
+ # catch a broken Dockerfile on the PR, not at deploy
26
+ - run: docker build --tag app:ci .
@@ -9,7 +9,7 @@ export interface CommandDefinition<S extends z.ZodType = z.ZodType> {
9
9
  /** `module:action`, e.g. "billing:recalculate" */
10
10
  name: string;
11
11
  description: string;
12
- /** flags parsed from `--key value`; use z.coerce for numbers and booleans */
12
+ /** flags parsed from `--key value`; use z.coerce for numbers and `flag()` for booleans */
13
13
  args?: S;
14
14
  run(args: z.infer<S>, ctx: CommandContext): Promise<void> | void;
15
15
  }
@@ -23,3 +23,11 @@ export function defineCommand<S extends z.ZodType = typeof noArgs>(
23
23
  }
24
24
 
25
25
  export const noArgs = z.object({});
26
+
27
+ /**
28
+ * A boolean flag: bare `--force` is true, `--force false|0|no|off` is false.
29
+ * (`z.coerce.boolean()` would read the string "false" as true.)
30
+ */
31
+ export function flag() {
32
+ return z.union([z.boolean(), z.stringbool()]).default(false);
33
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { flag } from "#core/commands/define";
4
+ import { parseFlags } from "#core/commands/runner";
5
+
6
+ const args = z.object({ force: flag() });
7
+ const parse = (argv: string[]) => args.parse(parseFlags(argv)).force;
8
+
9
+ describe("flag()", () => {
10
+ it("is true for a bare flag", () => expect(parse(["--force"])).toBe(true));
11
+ it("reads --flag false as false", () => expect(parse(["--force", "false"])).toBe(false));
12
+ it("reads --flag=0 as false", () => expect(parse(["--force=0"])).toBe(false));
13
+ it("reads --flag true as true", () => expect(parse(["--force", "true"])).toBe(true));
14
+ it("defaults to false", () => expect(parse([])).toBe(false));
15
+ it("rejects anything else", () => expect(() => parse(["--force", "maybe"])).toThrow());
16
+ });
@@ -0,0 +1,40 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // .env.test is loaded into process.env, so it holds every variable this app requires
4
+ const saved = { ...process.env };
5
+
6
+ const LOCAL_DEFAULTS = [
7
+ "APP_URL",
8
+ "CORS_ORIGINS",
9
+ "EMAIL_DRIVER",
10
+ "STORAGE_DRIVER",
11
+ "SMTP_URL",
12
+ "KEYCLOAK_URL",
13
+ ].filter((key) => saved[key] !== undefined);
14
+
15
+ async function loadEnv(vars: Record<string, string | undefined>) {
16
+ vi.resetModules();
17
+ process.env = { ...vars } as NodeJS.ProcessEnv;
18
+ return (await import("#core/config/env")).env;
19
+ }
20
+
21
+ afterEach(() => {
22
+ process.env = { ...saved };
23
+ vi.resetModules();
24
+ });
25
+
26
+ describe("env in production", () => {
27
+ it("loads when every setting is given", async () => {
28
+ const env = await loadEnv({ ...saved, NODE_ENV: "production" });
29
+ expect(env.NODE_ENV).toBe("production");
30
+ });
31
+
32
+ it.each(LOCAL_DEFAULTS)("refuses to fall back to the local default for %s", async (key) => {
33
+ await expect(loadEnv({ ...saved, NODE_ENV: "production", [key]: undefined })).rejects.toThrow();
34
+ });
35
+
36
+ it("keeps local defaults outside production", async () => {
37
+ const env = await loadEnv({ ...saved, NODE_ENV: "development", APP_URL: undefined });
38
+ expect(env.APP_URL).toBe("http://localhost:3000");
39
+ });
40
+ });
@@ -43,7 +43,12 @@ export function createHttpApp(name: AppName, options: { title: string }) {
43
43
  if (definition.app === name) app.route(`${API_PREFIX}${definition.basePath}`, definition.router);
44
44
  }
45
45
 
46
- app.doc31("/openapi.json", { openapi: "3.1.0", info: { title: options.title, version: "1.0.0" } });
46
+ const openApiConfig = { openapi: "3.1.0", info: { title: options.title, version: "1.0.0" } } as const;
47
+ app.get("/openapi.json", async (c) => {
48
+ const document = app.getOpenAPI31Document(openApiConfig);
49
+ // tatara:slot openapi
50
+ return c.json(document);
51
+ });
47
52
  app.get("/docs", Scalar({ url: "/openapi.json", pageTitle: options.title }));
48
53
 
49
54
  app.onError(errorHandler);
@@ -4,48 +4,76 @@ export class AppError extends Error {
4
4
  readonly status: ContentfulStatusCode;
5
5
  readonly code: string;
6
6
  readonly details?: unknown;
7
+ /** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
8
+ readonly translatable: boolean;
7
9
 
8
- constructor(status: ContentfulStatusCode, code: string, message: string, details?: unknown) {
10
+ constructor(
11
+ status: ContentfulStatusCode,
12
+ code: string,
13
+ message: string,
14
+ details?: unknown,
15
+ translatable = false,
16
+ ) {
9
17
  super(message);
10
18
  this.name = new.target.name;
11
19
  this.status = status;
12
20
  this.code = code;
13
21
  this.details = details;
22
+ this.translatable = translatable;
14
23
  }
15
24
  }
16
25
 
17
26
  export class ValidationError extends AppError {
18
- constructor(message = "Invalid request", details?: unknown) {
19
- super(400, "VALIDATION_ERROR", message, details);
27
+ constructor(message?: string, details?: unknown) {
28
+ super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === undefined);
20
29
  }
21
30
  }
22
31
 
23
32
  export class BusinessRuleError extends AppError {
24
33
  constructor(code: string, message: string, details?: unknown) {
25
- super(400, code, message, details);
34
+ // codes are specific to the rule, so a translation for the code is never less precise
35
+ super(400, code, message, details, true);
26
36
  }
27
37
  }
28
38
 
29
39
  export class UnauthorizedError extends AppError {
30
- constructor(message = "Authentication required") {
31
- super(401, "UNAUTHORIZED", message);
40
+ constructor(message?: string) {
41
+ super(
42
+ 401,
43
+ "UNAUTHORIZED",
44
+ message ?? "Authentication required",
45
+ undefined,
46
+ message === undefined,
47
+ );
32
48
  }
33
49
  }
34
50
 
35
51
  export class ForbiddenError extends AppError {
36
- constructor(message = "You do not have permission to do this") {
37
- super(403, "FORBIDDEN", message);
52
+ constructor(message?: string) {
53
+ super(
54
+ 403,
55
+ "FORBIDDEN",
56
+ message ?? "You do not have permission to do this",
57
+ undefined,
58
+ message === undefined,
59
+ );
38
60
  }
39
61
  }
40
62
 
41
63
  export class NotFoundError extends AppError {
42
- constructor(resource = "Resource") {
43
- super(404, "NOT_FOUND", `${resource} not found`);
64
+ constructor(resource?: string) {
65
+ super(
66
+ 404,
67
+ "NOT_FOUND",
68
+ `${resource ?? "Resource"} not found`,
69
+ undefined,
70
+ resource === undefined,
71
+ );
44
72
  }
45
73
  }
46
74
 
47
75
  export class ConflictError extends AppError {
48
- constructor(message = "Resource already exists", details?: unknown) {
49
- super(409, "CONFLICT", message, details);
76
+ constructor(message?: string, details?: unknown) {
77
+ super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
50
78
  }
51
79
  }
@@ -6,7 +6,7 @@ import { ValidationError } from "#core/http/errors";
6
6
  export const validationHook: Hook<any, AppEnv, any, any> = (result) => {
7
7
  if (!result.success) {
8
8
  throw new ValidationError(
9
- "Invalid request",
9
+ undefined,
10
10
  result.error.issues.map((issue) => ({
11
11
  path: issue.path.join("."),
12
12
  message: issue.message,
@@ -49,6 +49,11 @@ export function jsonContent<T extends z.ZodType>(schema: T, description: string)
49
49
  return { content: { "application/json": { schema } }, description };
50
50
  }
51
51
 
52
+ /** A required JSON request body. Without `required`, a request with no body skips validation. */
53
+ export function jsonBody<T extends z.ZodType>(schema: T, description: string) {
54
+ return { ...jsonContent(schema, description), required: true };
55
+ }
56
+
52
57
  const ERROR_DESCRIPTIONS = {
53
58
  400: "Invalid request",
54
59
  401: "Authentication required",
@@ -0,0 +1,60 @@
1
+ import type { OpenAPIHono } from "@hono/zod-openapi";
2
+ import { auth } from "#core/auth/auth";
3
+
4
+ type JsonObject = Record<string, unknown>;
5
+ type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
6
+ type Paths = NonNullable<OpenApiDocument["paths"]>;
7
+ type Components = NonNullable<OpenApiDocument["components"]>;
8
+
9
+ interface AuthDocument {
10
+ paths?: Record<string, JsonObject>;
11
+ components?: { schemas?: JsonObject; securitySchemes?: JsonObject };
12
+ }
13
+
14
+ const SCHEMA_REF = "#/components/schemas/";
15
+
16
+ // better-auth names schemas like the app (User, Member, …); prefix them so neither side is overwritten
17
+ function renameRefs(value: unknown, rename: (name: string) => string): AuthDocument {
18
+ return JSON.parse(
19
+ JSON.stringify(value).replaceAll(
20
+ new RegExp(`"\\$ref":"${SCHEMA_REF}([^"]+)"`, "g"),
21
+ (_match, name: string) => `"$ref":"${SCHEMA_REF}${rename(name)}"`,
22
+ ),
23
+ ) as AuthDocument;
24
+ }
25
+
26
+ function retag(paths: Record<string, JsonObject>, tag: string): Record<string, JsonObject> {
27
+ for (const operations of Object.values(paths)) {
28
+ for (const operation of Object.values(operations as Record<string, JsonObject>)) {
29
+ if (operation && typeof operation === "object") operation.tags = [tag];
30
+ }
31
+ }
32
+ return paths;
33
+ }
34
+
35
+ /** Adds better-auth's endpoints (served under `basePath`) to the app's OpenAPI document. */
36
+ export async function mergeAuthOpenApi(document: OpenApiDocument, basePath: string): Promise<void> {
37
+ const authDoc = renameRefs(await auth.api.generateOpenAPISchema(), (name) => `Auth${name}`);
38
+
39
+ document.paths ??= {};
40
+ for (const [path, operations] of Object.entries(retag(authDoc.paths ?? {}, "Auth"))) {
41
+ document.paths[`${basePath}${path}`] = operations as Paths[string];
42
+ }
43
+
44
+ document.components ??= {};
45
+ document.components.schemas ??= {};
46
+ for (const [name, schema] of Object.entries(authDoc.components?.schemas ?? {})) {
47
+ document.components.schemas[`Auth${name}`] = schema as NonNullable<
48
+ Components["schemas"]
49
+ >[string];
50
+ }
51
+ document.components.securitySchemes = {
52
+ ...(authDoc.components?.securitySchemes as Components["securitySchemes"]),
53
+ ...document.components.securitySchemes,
54
+ };
55
+
56
+ document.tags = [
57
+ ...(document.tags ?? []),
58
+ { name: "Auth", description: "Sign-up, sign-in, sessions, organizations (better-auth)" },
59
+ ];
60
+ }
@@ -2,6 +2,23 @@ import { createMiddleware } from "hono/factory";
2
2
  import { auth } from "#core/auth/auth";
3
3
  import type { AppEnv } from "#core/http/context";
4
4
 
5
+ /**
6
+ * The session's active organization, or null once the user stops being a member of it.
7
+ * better-auth leaves `activeOrganizationId` on the removed member's session.
8
+ */
9
+ async function activeMembership(userId: string, orgId: string | null | undefined) {
10
+ if (!orgId) return null;
11
+ const { adapter } = await auth.$context;
12
+ const member = await adapter.findOne({
13
+ model: "member",
14
+ where: [
15
+ { field: "organizationId", value: orgId },
16
+ { field: "userId", value: userId },
17
+ ],
18
+ });
19
+ return member ? orgId : null;
20
+ }
21
+
5
22
  /** Resolves the session (cookie or bearer token) into `c.var.principal`. */
6
23
  export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
7
24
  const session = await auth.api.getSession({ headers: c.req.raw.headers });
@@ -10,7 +27,7 @@ export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
10
27
  const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
11
28
  c.set("principal", {
12
29
  userId: user.id,
13
- orgId: s.activeOrganizationId ?? null,
30
+ orgId: await activeMembership(user.id, s.activeOrganizationId),
14
31
  roles: (user.role ?? "user")
15
32
  .split(",")
16
33
  .map((role) => role.trim())
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+
4
+ interface Document {
5
+ paths: Record<string, Record<string, { tags?: string[] }>>;
6
+ components: { schemas: Record<string, unknown>; securitySchemes: Record<string, unknown> };
7
+ }
8
+
9
+ async function apiDocument(): Promise<Document> {
10
+ const res = await api.get<Document>("/openapi.json");
11
+ expect(res.status).toBe(200);
12
+ return res.body;
13
+ }
14
+
15
+ function refs(value: unknown): string[] {
16
+ return [...JSON.stringify(value).matchAll(/"\$ref":"#\/components\/schemas\/([^"]+)"/g)].map(
17
+ (match) => match[1] ?? "",
18
+ );
19
+ }
20
+
21
+ describe("OpenAPI document", () => {
22
+ it("documents better-auth endpoints next to the app's own", async () => {
23
+ const doc = await apiDocument();
24
+ expect(doc.paths["/api/auth/get-session"]?.get).toBeDefined();
25
+ expect(doc.paths["/api/me"]?.get).toBeDefined();
26
+ });
27
+
28
+ it("tags better-auth operations as Auth", async () => {
29
+ const doc = await apiDocument();
30
+ expect(doc.paths["/api/auth/get-session"]?.get?.tags).toEqual(["Auth"]);
31
+ });
32
+
33
+ it("prefixes better-auth schemas so app schemas keep their names", async () => {
34
+ const doc = await apiDocument();
35
+ expect(doc.components.schemas.AuthUser).toBeDefined();
36
+ expect(doc.components.schemas.Principal).toBeDefined();
37
+ expect(doc.components.securitySchemes.bearerAuth).toBeDefined();
38
+ });
39
+
40
+ it("resolves every schema reference", async () => {
41
+ const doc = await apiDocument();
42
+ const missing = refs(doc).filter((name) => !(name in doc.components.schemas));
43
+ expect(missing).toEqual([]);
44
+ });
45
+ });
@@ -0,0 +1,8 @@
1
+ import { expect, it } from "vitest";
2
+ import { adminApi } from "#tests/helpers/api";
3
+
4
+ it("leaves auth out of the admin OpenAPI document", async () => {
5
+ const res = await adminApi.get<{ paths: Record<string, unknown> }>("/openapi.json");
6
+ expect(res.status).toBe(200);
7
+ expect(Object.keys(res.body.paths).some((path) => path.startsWith("/api/auth/"))).toBe(false);
8
+ });
@@ -0,0 +1,44 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { auth } from "#core/auth/auth";
3
+ import { api } from "#tests/helpers/api";
4
+ import { authenticate } from "#tests/helpers/auth";
5
+ import { resetDatabase } from "#tests/helpers/db";
6
+
7
+ beforeEach(async () => {
8
+ await resetDatabase();
9
+ });
10
+
11
+ describe("principal", () => {
12
+ it("drops the active organization once the user is no longer a member", async () => {
13
+ const owner = await authenticate();
14
+ const member = await authenticate({ email: "member@example.com" });
15
+ const org = await api.post<{ id: string }>(
16
+ "/api/auth/organization/create",
17
+ { name: "School", slug: "school" },
18
+ { headers: owner.headers },
19
+ );
20
+ await auth.api.addMember({
21
+ body: { userId: member.userId, role: "member", organizationId: org.body.id },
22
+ });
23
+ await api.post(
24
+ "/api/auth/organization/set-active",
25
+ { organizationId: org.body.id },
26
+ { headers: member.headers },
27
+ );
28
+ const before = await api.get<{ data: { orgId: string | null } }>("/api/me", {
29
+ headers: member.headers,
30
+ });
31
+ expect(before.body.data.orgId).toBe(org.body.id);
32
+
33
+ const removed = await api.post(
34
+ "/api/auth/organization/remove-member",
35
+ { memberIdOrEmail: "member@example.com", organizationId: org.body.id },
36
+ { headers: owner.headers },
37
+ );
38
+ expect(removed.status).toBe(200);
39
+ const after = await api.get<{ data: { orgId: string | null } }>("/api/me", {
40
+ headers: member.headers,
41
+ });
42
+ expect(after.body.data.orgId).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,40 @@
1
+ import { beforeEach, expect, it } from "vitest";
2
+ import { auth } from "#core/auth/auth";
3
+ import { api } from "#tests/helpers/api";
4
+ import { authenticate } from "#tests/helpers/auth";
5
+ import { applyTestAccess } from "#tests/helpers/authz";
6
+ import { resetDatabase } from "#tests/helpers/db";
7
+
8
+ beforeEach(async () => {
9
+ await resetDatabase();
10
+ });
11
+
12
+ it("does not restore old org roles when a removed member is added back", async () => {
13
+ const owner = await authenticate();
14
+ const member = await authenticate({ email: "member@example.com" });
15
+ const org = await api.post<{ id: string }>(
16
+ "/api/auth/organization/create",
17
+ { name: "School", slug: "school" },
18
+ { headers: owner.headers },
19
+ );
20
+ await auth.api.addMember({
21
+ body: { userId: member.userId, role: "member", organizationId: org.body.id },
22
+ });
23
+ await applyTestAccess(member.userId, { systemRoles: ["admin"], orgId: org.body.id });
24
+ await api.post(
25
+ "/api/auth/organization/remove-member",
26
+ { memberIdOrEmail: "member@example.com", organizationId: org.body.id },
27
+ { headers: owner.headers },
28
+ );
29
+
30
+ await auth.api.addMember({
31
+ body: { userId: member.userId, role: "member", organizationId: org.body.id },
32
+ });
33
+ await api.post(
34
+ "/api/auth/organization/set-active",
35
+ { organizationId: org.body.id },
36
+ { headers: member.headers },
37
+ );
38
+ const roles = await api.get("/api/authorization/roles", { headers: member.headers });
39
+ expect(roles.status).toBe(403);
40
+ });
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineCommand } from "#core/commands/define";
2
+ import { defineCommand, flag } from "#core/commands/define";
3
3
  import { env } from "#core/config/env";
4
4
  import { dropAllTables } from "#core/db/admin";
5
5
  import { migrateDatabase } from "#core/db/migrate";
@@ -7,7 +7,7 @@ import { migrateDatabase } from "#core/db/migrate";
7
7
  export default defineCommand({
8
8
  name: "db:reset",
9
9
  description: "Drop every table and re-apply migrations (refuses to run in production)",
10
- args: z.object({ force: z.coerce.boolean().default(false) }),
10
+ args: z.object({ force: flag() }),
11
11
  async run({ force }, { logger }) {
12
12
  if (env.NODE_ENV === "production" && !force) {
13
13
  throw new Error("db:reset refuses to run with NODE_ENV=production (pass --force to override)");
@@ -0,0 +1,7 @@
1
+ import { expect, it } from "vitest";
2
+ import { parseFlags } from "#core/commands/runner";
3
+ import reset from "#core/db/commands/reset";
4
+
5
+ it("does not treat --force false as forcing db:reset", () => {
6
+ expect(reset.args?.parse(parseFlags(["--force", "false"]))).toEqual({ force: false });
7
+ });
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
- import { defineCommand } from "#core/commands/define";
2
+ import { defineCommand, flag } from "#core/commands/define";
3
3
 
4
4
  export default defineCommand({
5
5
  name: "__moduleKebab__:__nameKebab__",
6
6
  description: "__nameTitle__",
7
- args: z.object({ dryRun: z.coerce.boolean().default(false) }),
7
+ args: z.object({ dryRun: flag() }),
8
8
  async run({ dryRun }, { logger }) {
9
9
  logger.info({ dryRun }, "__moduleKebab__:__nameKebab__ ran");
10
10
  },
@@ -1,6 +1,7 @@
1
1
  import { createRoute } from "@hono/zod-openapi";
2
2
  import {
3
3
  errorResponses,
4
+ jsonBody,
4
5
  jsonContent,
5
6
  paginated,
6
7
  paginatedSchema,
@@ -72,7 +73,7 @@ router.openapi(
72
73
  tags,
73
74
  summary: "Create a __nameKebab__",
74
75
  // tatara:slot guard-create
75
- request: { body: jsonContent(create__namePascal__Schema, "The new __nameKebab__") },
76
+ request: { body: jsonBody(create__namePascal__Schema, "The new __nameKebab__") },
76
77
  responses: {
77
78
  201: jsonContent(successSchema(__nameCamel__Schema), "Created"),
78
79
  ...errorResponses(400),
@@ -91,7 +92,7 @@ router.openapi(
91
92
  // tatara:slot guard-update
92
93
  request: {
93
94
  params: __nameCamel__ParamsSchema,
94
- body: jsonContent(update__namePascal__Schema, "Fields to change"),
95
+ body: jsonBody(update__namePascal__Schema, "Fields to change"),
95
96
  },
96
97
  responses: {
97
98
  200: jsonContent(successSchema(__nameCamel__Schema), "Updated"),