@tatara/cli 0.0.0-stage → 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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.mjs +3001 -0
  3. package/package.json +32 -3
  4. package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
  5. package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
  6. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
  7. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
  8. package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
  9. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
  10. package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
  11. package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
  12. package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
  13. package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
  14. package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
  15. package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
  16. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
  17. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
  18. package/templates/features/authz/files/src/core/authz/constants.ts +32 -0
  19. package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
  20. package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
  21. package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
  22. package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
  23. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
  24. package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
  25. package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
  26. package/templates/features/authz/files/src/core/authz/services/members.ts +84 -0
  27. package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
  28. package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
  29. package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
  30. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
  31. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  32. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  33. package/templates/features/authz/files/src/core/authz/types.ts +87 -0
  34. package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
  35. package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
  36. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +275 -0
  37. package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
  38. package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
  39. package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
  40. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +236 -0
  41. package/templates/features/base/files/AGENTS.md +71 -0
  42. package/templates/features/base/files/CLAUDE.md +1 -0
  43. package/templates/features/base/files/Dockerfile +16 -0
  44. package/templates/features/base/files/README.md +31 -0
  45. package/templates/features/base/files/_github/workflows/ci.yml +26 -0
  46. package/templates/features/base/files/biome.template.json +20 -0
  47. package/templates/features/base/files/dockerignore +8 -0
  48. package/templates/features/base/files/gitignore +9 -0
  49. package/templates/features/base/files/src/apps/api/app.ts +3 -0
  50. package/templates/features/base/files/src/apps/api/server.ts +5 -0
  51. package/templates/features/base/files/src/apps/cli/index.ts +6 -0
  52. package/templates/features/base/files/src/core/commands/define.ts +33 -0
  53. package/templates/features/base/files/src/core/commands/runner.ts +54 -0
  54. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  55. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  56. package/templates/features/base/files/src/core/http/app.ts +57 -0
  57. package/templates/features/base/files/src/core/http/context.ts +18 -0
  58. package/templates/features/base/files/src/core/http/errors.ts +79 -0
  59. package/templates/features/base/files/src/core/http/health.ts +25 -0
  60. package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
  61. package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
  62. package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
  63. package/templates/features/base/files/src/core/http/response.ts +72 -0
  64. package/templates/features/base/files/src/core/http/routes.ts +28 -0
  65. package/templates/features/base/files/src/core/http/server.ts +15 -0
  66. package/templates/features/base/files/src/core/logging/logger.ts +17 -0
  67. package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
  68. package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
  69. package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
  70. package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
  71. package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
  72. package/templates/features/base/files/tests/helpers/api.ts +54 -0
  73. package/templates/features/base/files/tests/setup.ts +2 -0
  74. package/templates/features/base/files/tsconfig.json +20 -0
  75. package/templates/features/base/files/tsdown.config.ts +15 -0
  76. package/templates/features/base/files/vitest.config.ts +16 -0
  77. package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
  78. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
  79. package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
  80. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  81. package/templates/features/better-auth/files/src/core/auth/principal.ts +40 -0
  82. package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
  83. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  84. package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
  85. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  86. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  87. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  88. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
  89. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
  90. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
  91. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
  92. package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
  93. package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
  94. package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
  95. package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
  96. package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
  97. package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
  98. package/templates/features/db/files/src/core/db/id.ts +13 -0
  99. package/templates/features/db/files/src/core/db/seed.ts +14 -0
  100. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  101. package/templates/features/db/files/tests/global-setup.ts +27 -0
  102. package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
  103. package/templates/features/db/files/tests/helpers/db.ts +7 -0
  104. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
  105. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
  106. package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
  107. package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
  108. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
  109. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
  110. package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
  111. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
  112. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
  113. package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
  114. package/templates/features/email/files/src/core/email/send.ts +22 -0
  115. package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
  116. package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
  117. package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
  118. package/templates/features/i18n/files/src/locales/ar.json +10 -0
  119. package/templates/features/i18n/files/src/locales/en.json +10 -0
  120. package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
  121. package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
  122. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
  123. package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
  124. package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
  125. package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
  126. package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
  127. package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
  128. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
  129. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
  130. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
  131. package/templates/features/keycloak/files/keycloak/realm.json +83 -0
  132. package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
  133. package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
  134. package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
  135. package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
  136. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
  137. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
  138. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
  139. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
  140. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
  141. package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
  142. package/templates/features/prisma/files/prisma.config.ts +10 -0
  143. package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
  144. package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
  145. package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
  146. package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
  147. package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
  148. package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
  149. package/templates/features/storage/files/src/core/storage/index.ts +8 -0
  150. package/templates/features/storage/files/src/core/storage/local.ts +34 -0
  151. package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
  152. package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
  153. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
  154. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
  155. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
  156. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
  157. package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
  158. package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
  159. package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
  160. package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
  161. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
  162. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +127 -0
  163. package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
  164. package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
  165. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
  166. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
  167. package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  168. package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  169. package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  170. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
  171. package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
  172. package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
  173. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
  174. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
  175. package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  176. package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  177. package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  178. package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
  179. package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
  180. package/templates/versions.json +34 -0
  181. package/README.md +0 -3
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2024",
4
+ "lib": ["es2024"],
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "noImplicitOverride": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "erasableSyntaxOnly": true,
12
+ "verbatimModuleSyntax": true,
13
+ "allowImportingTsExtensions": true,
14
+ "noEmit": true,
15
+ "skipLibCheck": true,
16
+ "resolveJsonModule": true,
17
+ "types": ["node"]
18
+ },
19
+ "include": ["src", "tests", "*.config.ts"]
20
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: {
5
+ "apps/api/server": "src/apps/api/server.ts",
6
+ "apps/cli/index": "src/apps/cli/index.ts",
7
+ // tatara:slot entries
8
+ },
9
+ format: "esm",
10
+ platform: "node",
11
+ target: "node24",
12
+ outDir: "dist",
13
+ dts: false,
14
+ clean: true,
15
+ });
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { parseEnv } from "node:util";
3
+ import { defineConfig } from "vitest/config";
4
+
5
+ const env = existsSync(".env.test")
6
+ ? (parseEnv(readFileSync(".env.test", "utf8")) as Record<string, string>)
7
+ : {};
8
+
9
+ export default defineConfig({
10
+ test: {
11
+ include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
12
+ env,
13
+ setupFiles: ["tests/setup.ts"],
14
+ // tatara:slot vitest
15
+ },
16
+ });
@@ -0,0 +1,26 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { bearer, openAPI } from "better-auth/plugins";
3
+ import { database } from "#core/auth/adapter";
4
+ import { env } from "#core/config/env";
5
+ import { newId } from "#core/db/id";
6
+ // tatara:slot imports
7
+
8
+ export const auth = betterAuth({
9
+ appName: "__APP_NAME__",
10
+ baseURL: env.APP_URL,
11
+ basePath: "/api/auth",
12
+ secret: env.BETTER_AUTH_SECRET,
13
+ trustedOrigins: env.CORS_ORIGINS,
14
+ database,
15
+ advanced: { database: { generateId: () => newId() } },
16
+ // tatara:slot options
17
+ plugins: [
18
+ // Authorization: Bearer <session token> for mobile and API clients
19
+ bearer(),
20
+ // reference for the auth endpoints at /api/auth/reference
21
+ openAPI(),
22
+ // tatara:slot plugins
23
+ ],
24
+ });
25
+
26
+ export type Session = typeof auth.$Infer.Session;
@@ -0,0 +1,24 @@
1
+ import { auth } from "#core/auth/auth";
2
+ import { env } from "#core/config/env";
3
+ import { defineSeed } from "#core/db/seed";
4
+
5
+ /** Development admin account (never created in production). */
6
+ export default defineSeed({
7
+ name: "auth:dev-admin",
8
+ order: 0,
9
+ async run({ logger }) {
10
+ if (env.NODE_ENV !== "development" && env.NODE_ENV !== "test") return;
11
+ const ctx = await auth.$context;
12
+ const email = env.SEED_ADMIN_EMAIL;
13
+ const existing = await ctx.internalAdapter.findUserByEmail(email);
14
+ if (existing) return;
15
+ const user = await ctx.internalAdapter.createUser({
16
+ email,
17
+ name: "Dev admin",
18
+ emailVerified: true,
19
+ // tatara:slot seed-user-fields
20
+ }, { method: "admin" });
21
+ // tatara:slot seed-after-user
22
+ logger.info({ email, userId: user.id }, "created development admin");
23
+ },
24
+ });
@@ -0,0 +1,48 @@
1
+ import { smsSender } from "#core/auth/sms";
2
+ import { env } from "#core/config/env";
3
+ // tatara:slot imports
4
+
5
+ export interface OutboxMessage {
6
+ channel: "email" | "sms";
7
+ to: string;
8
+ subject?: string;
9
+ text: string;
10
+ /** one-time code, when the message carries one */
11
+ code?: string;
12
+ sentAt: string;
13
+ }
14
+
15
+ const outbox: OutboxMessage[] = [];
16
+
17
+ /** Only with DEV_OUTBOX=true in development or test: the outbox exposes sign-in codes. */
18
+ export function outboxEnabled(): boolean {
19
+ return env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test");
20
+ }
21
+
22
+ /** Messages are kept here so tests and local sign-in can read codes. */
23
+ function record(message: Omit<OutboxMessage, "sentAt">) {
24
+ if (!outboxEnabled()) return;
25
+ outbox.push({ ...message, sentAt: new Date().toISOString() });
26
+ if (outbox.length > 200) outbox.shift();
27
+ }
28
+
29
+ /** Newest first. Empty unless the outbox is enabled. */
30
+ export function readOutbox(to?: string): OutboxMessage[] {
31
+ if (!outboxEnabled()) return [];
32
+ return outbox.filter((m) => !to || m.to === to).reverse();
33
+ }
34
+
35
+ export async function deliverEmail(message: {
36
+ to: string;
37
+ subject: string;
38
+ text: string;
39
+ code?: string;
40
+ }): Promise<void> {
41
+ record({ channel: "email", ...message });
42
+ // tatara:slot email-delivery
43
+ }
44
+
45
+ export async function deliverSms(message: { to: string; text: string; code?: string }): Promise<void> {
46
+ record({ channel: "sms", ...message });
47
+ await smsSender.send(message.to, message.text);
48
+ }
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import { auth } from "#core/auth/auth";
3
+ import type { AppEnv } from "#core/http/context";
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
+
22
+ /** Resolves the session (cookie or bearer token) into `c.var.principal`. */
23
+ export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
24
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
25
+ if (session) {
26
+ const user = session.user as typeof session.user & { role?: string | null };
27
+ const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
28
+ c.set("principal", {
29
+ userId: user.id,
30
+ orgId: await activeMembership(user.id, s.activeOrganizationId),
31
+ roles: (user.role ?? "user")
32
+ .split(",")
33
+ .map((role) => role.trim())
34
+ .filter(Boolean),
35
+ kind: "user",
36
+ claims: {},
37
+ });
38
+ }
39
+ await next();
40
+ });
@@ -0,0 +1,14 @@
1
+ import { logger } from "#core/logging/logger";
2
+
3
+ /** Implement this for your SMS provider and return it from `smsSender`. */
4
+ export interface SmsSender {
5
+ send(to: string, text: string): Promise<void>;
6
+ }
7
+
8
+ const consoleSender: SmsSender = {
9
+ async send(to) {
10
+ logger.info({ to }, "sms not sent: no SMS provider configured (see src/core/auth/sms.ts)");
11
+ },
12
+ };
13
+
14
+ export const smsSender: SmsSender = consoleSender;
@@ -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,40 @@
1
+ import { auth } from "#core/auth/auth";
2
+ import { newId } from "#core/db/id";
3
+ // tatara:slot imports
4
+
5
+ export interface AsUserOptions {
6
+ email?: string;
7
+ name?: string;
8
+ /** user roles (admin plugin); ignored without organizations + roles */
9
+ roles?: string[];
10
+ // tatara:slot options
11
+ }
12
+
13
+ /** Creates a verified user and returns their id. */
14
+ export async function createTestUser(options: AsUserOptions = {}): Promise<string> {
15
+ const ctx = await auth.$context;
16
+ const user = await ctx.internalAdapter.createUser({
17
+ email: options.email ?? `user-${newId()}@example.com`,
18
+ name: options.name ?? "Test User",
19
+ emailVerified: true,
20
+ // tatara:slot user-fields
21
+ }, { method: "admin" });
22
+ return user.id;
23
+ }
24
+
25
+ /** A new user with a real session; the token is sent as a bearer token. */
26
+ export async function authenticate(
27
+ options: AsUserOptions = {},
28
+ ): Promise<{ userId: string; headers: Record<string, string> }> {
29
+ const userId = await createTestUser(options);
30
+ const ctx = await auth.$context;
31
+ const session = await ctx.internalAdapter.createSession(userId);
32
+ return { userId, headers: { authorization: `Bearer ${session.token}` } };
33
+ }
34
+
35
+ /** Headers that authenticate as a new user. */
36
+ export async function asUser(options: AsUserOptions = {}): Promise<Record<string, string>> {
37
+ const user = await authenticate(options);
38
+ // tatara:slot as-user
39
+ return user.headers;
40
+ }
@@ -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
+ });
@@ -0,0 +1,15 @@
1
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
2
+ import * as authSchema from "#core/auth/db/auth.schema";
3
+ import { db } from "#core/db/client";
4
+ // tatara:slot imports
5
+
6
+ export const database = drizzleAdapter(db, {
7
+ provider: "mysql",
8
+ schema: {
9
+ user: authSchema.users,
10
+ session: authSchema.sessions,
11
+ account: authSchema.accounts,
12
+ verification: authSchema.verifications,
13
+ // tatara:slot adapter-schema
14
+ },
15
+ });
@@ -0,0 +1,57 @@
1
+ import { boolean, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
2
+
3
+ const id = () => varchar({ length: 36 });
4
+ const createdAt = () => timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow();
5
+ const updatedAt = () => timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow();
6
+
7
+ export const users = mysqlTable("users", {
8
+ id: id().primaryKey(),
9
+ name: varchar({ length: 255 }).notNull(),
10
+ email: varchar({ length: 255 }).notNull().unique(),
11
+ emailVerified: boolean().notNull().default(false),
12
+ image: text(),
13
+ // tatara:slot user-fields
14
+ createdAt: createdAt(),
15
+ updatedAt: updatedAt(),
16
+ });
17
+
18
+ export const sessions = mysqlTable("sessions", {
19
+ id: id().primaryKey(),
20
+ expiresAt: timestamp({ fsp: 3, mode: "date" }).notNull(),
21
+ token: varchar({ length: 255 }).notNull().unique(),
22
+ ipAddress: varchar({ length: 64 }),
23
+ userAgent: text(),
24
+ userId: id()
25
+ .notNull()
26
+ .references(() => users.id, { onDelete: "cascade" }),
27
+ // tatara:slot session-fields
28
+ createdAt: createdAt(),
29
+ updatedAt: updatedAt(),
30
+ });
31
+
32
+ export const accounts = mysqlTable("accounts", {
33
+ id: id().primaryKey(),
34
+ accountId: varchar({ length: 255 }).notNull(),
35
+ providerId: varchar({ length: 64 }).notNull(),
36
+ userId: id()
37
+ .notNull()
38
+ .references(() => users.id, { onDelete: "cascade" }),
39
+ accessToken: text(),
40
+ refreshToken: text(),
41
+ idToken: text(),
42
+ accessTokenExpiresAt: timestamp({ fsp: 3, mode: "date" }),
43
+ refreshTokenExpiresAt: timestamp({ fsp: 3, mode: "date" }),
44
+ scope: text(),
45
+ password: text(),
46
+ createdAt: createdAt(),
47
+ updatedAt: updatedAt(),
48
+ });
49
+
50
+ export const verifications = mysqlTable("verifications", {
51
+ id: id().primaryKey(),
52
+ identifier: varchar({ length: 255 }).notNull(),
53
+ value: text().notNull(),
54
+ expiresAt: timestamp({ fsp: 3, mode: "date" }).notNull(),
55
+ createdAt: createdAt(),
56
+ updatedAt: updatedAt(),
57
+ });
@@ -0,0 +1,15 @@
1
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
2
+ import * as authSchema from "#core/auth/db/auth.schema";
3
+ import { db } from "#core/db/client";
4
+ // tatara:slot imports
5
+
6
+ export const database = drizzleAdapter(db, {
7
+ provider: "pg",
8
+ schema: {
9
+ user: authSchema.users,
10
+ session: authSchema.sessions,
11
+ account: authSchema.accounts,
12
+ verification: authSchema.verifications,
13
+ // tatara:slot adapter-schema
14
+ },
15
+ });
@@ -0,0 +1,56 @@
1
+ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
+
3
+ const createdAt = () => timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow();
4
+ const updatedAt = () => timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow();
5
+
6
+ export const users = pgTable("users", {
7
+ id: text().primaryKey(),
8
+ name: text().notNull(),
9
+ email: text().notNull().unique(),
10
+ emailVerified: boolean().notNull().default(false),
11
+ image: text(),
12
+ // tatara:slot user-fields
13
+ createdAt: createdAt(),
14
+ updatedAt: updatedAt(),
15
+ });
16
+
17
+ export const sessions = pgTable("sessions", {
18
+ id: text().primaryKey(),
19
+ expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(),
20
+ token: text().notNull().unique(),
21
+ ipAddress: text(),
22
+ userAgent: text(),
23
+ userId: text()
24
+ .notNull()
25
+ .references(() => users.id, { onDelete: "cascade" }),
26
+ // tatara:slot session-fields
27
+ createdAt: createdAt(),
28
+ updatedAt: updatedAt(),
29
+ });
30
+
31
+ export const accounts = pgTable("accounts", {
32
+ id: text().primaryKey(),
33
+ accountId: text().notNull(),
34
+ providerId: text().notNull(),
35
+ userId: text()
36
+ .notNull()
37
+ .references(() => users.id, { onDelete: "cascade" }),
38
+ accessToken: text(),
39
+ refreshToken: text(),
40
+ idToken: text(),
41
+ accessTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }),
42
+ refreshTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }),
43
+ scope: text(),
44
+ password: text(),
45
+ createdAt: createdAt(),
46
+ updatedAt: updatedAt(),
47
+ });
48
+
49
+ export const verifications = pgTable("verifications", {
50
+ id: text().primaryKey(),
51
+ identifier: text().notNull(),
52
+ value: text().notNull(),
53
+ expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(),
54
+ createdAt: createdAt(),
55
+ updatedAt: updatedAt(),
56
+ });
@@ -0,0 +1,62 @@
1
+ // better-auth tables. Field names are what better-auth expects; columns are snake_case.
2
+
3
+ model User {
4
+ id String @id @db.VarChar(36)
5
+ name String @db.VarChar(255)
6
+ email String @unique @db.VarChar(255)
7
+ emailVerified Boolean @default(false) @map("email_verified")
8
+ image String? @db.Text
9
+ // tatara:slot user-fields
10
+ createdAt DateTime @default(now()) @map("created_at")
11
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
12
+ sessions Session[]
13
+ accounts Account[]
14
+ // tatara:slot user-relations
15
+
16
+ @@map("users")
17
+ }
18
+
19
+ model Session {
20
+ id String @id @db.VarChar(36)
21
+ expiresAt DateTime @map("expires_at")
22
+ token String @unique @db.VarChar(255)
23
+ ipAddress String? @map("ip_address") @db.VarChar(64)
24
+ userAgent String? @map("user_agent") @db.Text
25
+ userId String @map("user_id") @db.VarChar(36)
26
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
27
+ // tatara:slot session-fields
28
+ createdAt DateTime @default(now()) @map("created_at")
29
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
30
+
31
+ @@map("sessions")
32
+ }
33
+
34
+ model Account {
35
+ id String @id @db.VarChar(36)
36
+ accountId String @map("account_id") @db.VarChar(255)
37
+ providerId String @map("provider_id") @db.VarChar(64)
38
+ userId String @map("user_id") @db.VarChar(36)
39
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
40
+ accessToken String? @map("access_token") @db.Text
41
+ refreshToken String? @map("refresh_token") @db.Text
42
+ idToken String? @map("id_token") @db.Text
43
+ accessTokenExpiresAt DateTime? @map("access_token_expires_at")
44
+ refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
45
+ scope String? @db.Text
46
+ password String? @db.Text
47
+ createdAt DateTime @default(now()) @map("created_at")
48
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
49
+
50
+ @@map("accounts")
51
+ }
52
+
53
+ model Verification {
54
+ id String @id @db.VarChar(36)
55
+ identifier String @db.VarChar(255)
56
+ value String @db.Text
57
+ expiresAt DateTime @map("expires_at")
58
+ createdAt DateTime @default(now()) @map("created_at")
59
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
60
+
61
+ @@map("verifications")
62
+ }
@@ -0,0 +1,4 @@
1
+ import { prismaAdapter } from "better-auth/adapters/prisma";
2
+ import { db } from "#core/db/client";
3
+
4
+ export const database = prismaAdapter(db, { provider: "mysql" });